diff --git a/lib/console/index.ts b/lib/console/index.ts index 29b58e9c..59ab9e9a 100644 --- a/lib/console/index.ts +++ b/lib/console/index.ts @@ -1,6 +1,7 @@ import type Context from '../context'; import helpConsole from './help'; import initConsole from './init'; +import themeConsole from './theme'; import versionConsole from './version'; export = function(ctx: Context) { @@ -20,5 +21,14 @@ export = function(ctx: Context) { ] }, initConsole); + console.register('theme', 'Manage themes.', { + desc: 'Install and uninstall Hexo themes.', + usage: ' ', + commands: [ + {name: 'install ', desc: 'Install an official Hexo theme.'}, + {name: 'uninstall ', desc: 'Uninstall a Hexo theme.'} + ] + }, themeConsole); + console.register('version', 'Display version information.', {}, versionConsole); }; diff --git a/lib/console/theme.ts b/lib/console/theme.ts new file mode 100644 index 00000000..ca8e1f8b --- /dev/null +++ b/lib/console/theme.ts @@ -0,0 +1,171 @@ +import { copyFile, existsSync, mkdirs, rmdir, unlink } from 'hexo-fs'; +import spawn from 'hexo-util/dist/spawn'; +import { basename, join } from 'path'; +import { sync as commandExistsSync } from 'command-exists'; +import type Context from '../context'; + +const REGISTRY_URL = 'https://api.github.com/repos/hexojs/site/contents/source/_data/themes'; + +interface ThemeArgs { + _: string[]; +} + +interface ThemeRegistryResponse { + content: string; +} + +interface RegistryResponse { + ok: boolean; + status: number; + json(): Promise; +} + +async function themeConsole(this: Context, args: ThemeArgs) { + const [command, themeName, ...extraArgs] = args._; + + if ((command !== 'install' && command !== 'uninstall') || !themeName || extraArgs.length) { + throw new Error('Usage: hexo theme '); + } + + if (themeName !== basename(themeName) || themeName === '.' || themeName === '..') { + throw new Error('Theme name must not contain a path.'); + } + + if (command === 'uninstall') { + await uninstallTheme(this, themeName); + return; + } + + const target = join(this.base_dir, 'themes', themeName); + if (existsSync(target)) { + throw new Error(`Theme directory already exists: ${target}`); + } + + if (!commandExistsSync('git')) { + throw new Error('Git is required to install themes. Please install Git and try again.'); + } + + const repository = await getThemeRepository(themeName); + const themesDir = join(this.base_dir, 'themes'); + + await mkdirs(themesDir); + this.log.info('Installing theme %s', themeName); + + try { + await spawn('git', ['clone', '--depth=1', '--quiet', repository, target], { + stdio: 'inherit' + }); + + await installThemeDependencies(this.base_dir, target); + await copyThemeConfig(this, themeName, target); + } catch (err) { + await removeThemeDirectory(target); + throw err; + } + + this.log.info('Theme %s installed in %s', themeName, target); + this.log.info('Set `theme: %s` in _config.yml to use it.', themeName); +} + +async function getThemeRepository(themeName: string) { + const response = await fetchThemeRegistry(themeName); + + if (response.status === 404) { + throw new Error(`Theme "${themeName}" is not listed in the official Hexo theme registry.`); + } + + if (!response.ok) { + throw new Error(`Failed to fetch the official Hexo theme registry (HTTP ${response.status}).`); + } + + const payload = await response.json() as ThemeRegistryResponse; + const metadata = Buffer.from(payload.content, 'base64').toString('utf8'); + const link = metadata.split(/\r?\n/u).find(line => line.startsWith('link:'))!; + + return link.slice('link:'.length).trim(); +} + +async function fetchThemeRegistry(themeName: string): Promise { + try { + // fetch is stable in supported Node.js versions, but eslint-plugin-n still marks it as experimental before Node 21. + // eslint-disable-next-line n/no-unsupported-features/node-builtins + return await fetch(`${REGISTRY_URL}/${encodeURIComponent(themeName)}.yml`, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'hexo-cli' + } + }); + } catch { + throw new Error('Failed to fetch the official Hexo theme registry.'); + } +} + +async function installThemeDependencies(baseDir: string, target: string) { + if (!existsSync(join(target, 'package.json'))) return; + + const packageManager = findPackageManager(baseDir); + if (!commandExistsSync(packageManager)) { + throw new Error(`${packageManager} is required to install theme dependencies. Please install it and try again.`); + } + + let args: string[]; + if (packageManager === 'pnpm') { + args = ['install', '--prod']; + } else if (packageManager === 'yarn') { + args = ['install', '--production']; + } else { + args = ['install', '--omit=dev']; + } + + await spawn(packageManager, args, { + cwd: target, + stdio: 'inherit' + }); +} + +async function copyThemeConfig(ctx: Context, themeName: string, target: string) { + const source = join(target, '_config.yml'); + const destination = join(ctx.base_dir, `_config.${themeName}.yml`); + + if (!existsSync(source)) { + ctx.log.warn('Theme %s does not provide _config.yml; no theme config was created.', themeName); + return; + } + + if (existsSync(destination)) { + ctx.log.warn('Theme config already exists at %s; keeping the existing file.', destination); + return; + } + + await copyFile(source, destination); + ctx.log.info('Theme config copied to %s', destination); +} + +async function uninstallTheme(ctx: Context, themeName: string) { + const target = join(ctx.base_dir, 'themes', themeName); + const config = join(ctx.base_dir, `_config.${themeName}.yml`); + const hasTheme = existsSync(target); + const hasConfig = existsSync(config); + + if (!hasTheme && !hasConfig) { + throw new Error(`Theme "${themeName}" is not installed.`); + } + + if (hasTheme) await rmdir(target); + if (hasConfig) await unlink(config); + + ctx.log.info('Theme %s uninstalled.', themeName); +} + +function findPackageManager(baseDir: string) { + if (existsSync(join(baseDir, 'pnpm-lock.yaml'))) return 'pnpm'; + if (existsSync(join(baseDir, 'yarn.lock'))) return 'yarn'; + return 'npm'; +} + +async function removeThemeDirectory(target: string) { + if (!existsSync(target)) return; + await rmdir(target); +} + +export = themeConsole; diff --git a/lib/hexo.ts b/lib/hexo.ts index 810f840c..bb901176 100644 --- a/lib/hexo.ts +++ b/lib/hexo.ts @@ -11,6 +11,7 @@ import { camelCaseKeys } from 'hexo-util'; import registerConsole from './console'; import helpConsole from './console/help'; import initConsole from './console/init'; +import themeConsole from './console/theme'; import versionConsole from './console/version'; class HexoNotFoundError extends Error {} @@ -70,6 +71,7 @@ function entry(cwd = process.cwd(), args) { entry.console = { init: initConsole, help: helpConsole, + theme: themeConsole, version: versionConsole }; diff --git a/test/scripts/theme.ts b/test/scripts/theme.ts new file mode 100644 index 00000000..e3b8365a --- /dev/null +++ b/test/scripts/theme.ts @@ -0,0 +1,270 @@ +import chai from 'chai'; +import sinon from 'sinon'; +import rewire from 'rewire'; +import { join } from 'path'; +import { existsSync, mkdirs, readFile, rmdir, writeFile } from 'hexo-fs'; +import Context from '../../lib/context'; +import console from '../../lib/console/index'; +chai.should(); + +function registryResponse(link = 'https://github.com/example/hexo-theme-example') { + return { + ok: true, + status: 200, + json: async () => ({ + content: Buffer.from(`link: ${link}\n`).toString('base64') + }) + }; +} + +describe('theme', () => { + const baseDir = join(__dirname, 'theme_test'); + const themeModule = rewire('../../dist/console/theme'); + const theme = themeModule.bind(new Context(baseDir, {silent: true})); + + async function reset() { + if (existsSync(baseDir)) await rmdir(baseDir); + await mkdirs(baseDir); + } + + async function withMocks(fetchMock: sinon.SinonStub, spawnMock: sinon.SinonStub, fn: () => Promise) { + return themeModule.__with__({ + fetch: fetchMock, + 'spawn_1': {default: spawnMock}, + command_exists_1: {sync: () => true} + })(fn); + } + + async function expectError(promise: Promise, message: string) { + let error: Error | undefined; + + try { + await promise; + } catch (err) { + error = err as Error; + } + + chai.expect(error).to.be.instanceOf(Error); + error!.message.should.contain(message); + } + + beforeEach(reset); + after(async () => { + if (existsSync(baseDir)) await rmdir(baseDir); + }); + + it('registers the theme command', () => { + const hexo = new Context(); + console(hexo); + + const command = hexo.extend.console.get('theme'); + command?.options?.usage.should.eql(' '); + }); + + it('installs an official theme into the site themes directory', async () => { + const spawnMock = sinon.stub().resolves(); + const fetchMock = sinon.stub().resolves(registryResponse()); + + await withMocks(fetchMock, spawnMock, async () => { + await theme({_: ['install', 'example']}); + }); + + fetchMock.calledWithMatch('https://api.github.com/repos/hexojs/site/contents/source/_data/themes/example.yml').should.be.true; + spawnMock.calledOnce.should.be.true; + spawnMock.calledWith('git', [ + 'clone', '--depth=1', '--quiet', 'https://github.com/example/hexo-theme-example', join(baseDir, 'themes', 'example') + ]).should.be.true; + }); + + it('rejects names not found in the official registry', async () => { + const spawnMock = sinon.stub(); + const fetchMock = sinon.stub().resolves({ok: false, status: 404}); + + await withMocks(fetchMock, spawnMock, async () => { + await expectError(theme({_: ['install', 'missing']}), 'not listed'); + }); + + spawnMock.notCalled.should.be.true; + }); + + it('reports registry transport errors', async () => { + const spawnMock = sinon.stub(); + const fetchMock = sinon.stub(); + + fetchMock.rejects(new Error('offline')); + await withMocks(fetchMock, spawnMock, async () => { + await expectError(theme({_: ['install', 'example']}), 'Failed to fetch the official Hexo theme registry'); + }); + }); + + it('installs the repository link returned by the registry without extra validation', async () => { + const spawnMock = sinon.stub().resolves(); + const fetchMock = sinon.stub().resolves(registryResponse('https://example.com/theme')); + + await withMocks(fetchMock, spawnMock, async () => { + await theme({_: ['install', 'example']}); + }); + + spawnMock.calledWith('git', [ + 'clone', '--depth=1', '--quiet', 'https://example.com/theme', join(baseDir, 'themes', 'example') + ]).should.be.true; + }); + + it('rejects a pre-existing theme directory before fetching the registry', async () => { + const target = join(baseDir, 'themes', 'example'); + await mkdirs(target); + const fetchMock = sinon.stub(); + const spawnMock = sinon.stub(); + + await withMocks(fetchMock, spawnMock, async () => { + await expectError(theme({_: ['install', 'example']}), 'already exists'); + }); + + fetchMock.notCalled.should.be.true; + spawnMock.notCalled.should.be.true; + }); + + it('removes the new directory when cloning fails', async () => { + const target = join(baseDir, 'themes', 'example'); + const spawnMock = sinon.stub().callsFake(async () => { + await mkdirs(target); + throw new Error('clone failed'); + }); + const fetchMock = sinon.stub().resolves(registryResponse()); + + await withMocks(fetchMock, spawnMock, async () => { + await expectError(theme({_: ['install', 'example']}), 'clone failed'); + }); + + existsSync(target).should.be.false; + }); + + it('removes the new directory when dependency installation fails', async () => { + const target = join(baseDir, 'themes', 'example'); + const spawnMock = sinon.stub().callsFake(async (command: string) => { + if (command === 'git') { + await mkdirs(target); + await writeFile(join(target, 'package.json'), '{}'); + return; + } + + throw new Error('dependency install failed'); + }); + const fetchMock = sinon.stub().resolves(registryResponse()); + + await withMocks(fetchMock, spawnMock, async () => { + await expectError(theme({_: ['install', 'example']}), 'dependency install failed'); + }); + + existsSync(target).should.be.false; + }); + + it('installs production dependencies with the site package manager', async () => { + const target = join(baseDir, 'themes', 'example'); + await writeFile(join(baseDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n'); + const spawnMock = sinon.stub().callsFake(async (command: string) => { + if (command === 'git') { + await mkdirs(target); + await writeFile(join(target, 'package.json'), '{}'); + } + }); + const fetchMock = sinon.stub().resolves(registryResponse()); + + await withMocks(fetchMock, spawnMock, async () => { + await theme({_: ['install', 'example']}); + }); + + spawnMock.calledWith('pnpm', ['install', '--prod'], { + cwd: target, + stdio: 'inherit' + }).should.be.true; + }); + + it('copies the theme config to the site root', async () => { + const target = join(baseDir, 'themes', 'example'); + const spawnMock = sinon.stub().callsFake(async () => { + await mkdirs(target); + await writeFile(join(target, '_config.yml'), 'color: blue\n'); + }); + const fetchMock = sinon.stub().resolves(registryResponse()); + + await withMocks(fetchMock, spawnMock, async () => { + await theme({_: ['install', 'example']}); + }); + + const config = await readFile(join(baseDir, '_config.example.yml')); + config.should.eql('color: blue\n'); + }); + + it('does not overwrite an existing theme config', async () => { + const target = join(baseDir, 'themes', 'example'); + const configPath = join(baseDir, '_config.example.yml'); + await writeFile(configPath, 'color: red\n'); + const spawnMock = sinon.stub().callsFake(async () => { + await mkdirs(target); + await writeFile(join(target, '_config.yml'), 'color: blue\n'); + }); + const fetchMock = sinon.stub().resolves(registryResponse()); + + await withMocks(fetchMock, spawnMock, async () => { + await theme({_: ['install', 'example']}); + }); + + const config = await readFile(configPath); + config.should.eql('color: red\n'); + }); + + it('uninstalls the theme and its root config without accessing the registry', async () => { + const target = join(baseDir, 'themes', 'example'); + const configPath = join(baseDir, '_config.example.yml'); + await mkdirs(target); + await writeFile(join(target, 'layout.ejs'), ''); + await writeFile(configPath, 'color: blue\n'); + const fetchMock = sinon.stub(); + const spawnMock = sinon.stub(); + + await withMocks(fetchMock, spawnMock, async () => { + await theme({_: ['uninstall', 'example']}); + }); + + existsSync(target).should.be.false; + existsSync(configPath).should.be.false; + fetchMock.notCalled.should.be.true; + spawnMock.notCalled.should.be.true; + }); + + it('reports a theme that is not installed', async () => { + const fetchMock = sinon.stub(); + const spawnMock = sinon.stub(); + + await withMocks(fetchMock, spawnMock, async () => { + await expectError(theme({_: ['uninstall', 'missing']}), 'is not installed'); + }); + + fetchMock.notCalled.should.be.true; + spawnMock.notCalled.should.be.true; + }); + + it('selects package managers from lockfiles', async () => { + const findPackageManager = themeModule.__get__('findPackageManager'); + findPackageManager(baseDir).should.eql('npm'); + + await writeFile(join(baseDir, 'package-lock.json'), '{}'); + findPackageManager(baseDir).should.eql('npm'); + await writeFile(join(baseDir, 'yarn.lock'), ''); + findPackageManager(baseDir).should.eql('yarn'); + await writeFile(join(baseDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n'); + findPackageManager(baseDir).should.eql('pnpm'); + }); + + it('rejects invalid command usage and local paths', async () => { + const spawnMock = sinon.stub(); + const fetchMock = sinon.stub(); + + await withMocks(fetchMock, spawnMock, async () => { + await expectError(theme({_: ['install']}), 'Usage: hexo theme '); + await expectError(theme({_: ['install', '../example']}), 'must not contain a path'); + await expectError(theme({_: ['uninstall', '../example']}), 'must not contain a path'); + }); + }); +});