diff --git a/packages/cli/src/cmd/deploy.ts b/packages/cli/src/cmd/deploy.ts index 4ededc67d4..2238914eee 100755 --- a/packages/cli/src/cmd/deploy.ts +++ b/packages/cli/src/cmd/deploy.ts @@ -3,6 +3,7 @@ import { Site } from '@markbind/core'; import _ from 'lodash'; import * as cliUtil from '../util/cliUtil.js'; import * as logger from '../util/logger.js'; +import { logDeployResult } from '../util/deploy.js'; function deploy(userSpecifiedRoot: string, options: any) { let rootFolder; @@ -12,10 +13,10 @@ function deploy(userSpecifiedRoot: string, options: any) { if (_.isError(error)) { logger.error(error.message); logger.error('This directory does not appear to contain a valid MarkBind site. ' - + 'Check that you are running the command in the correct directory!\n' - + '\n' - + 'To create a new MarkBind site, run:\n' - + ' markbind init'); + + 'Check that you are running the command in the correct directory!\n' + + '\n' + + 'To create a new MarkBind site, run:\n' + + ' markbind init'); } else { logger.error(`Unknown error occurred: ${error}`); } @@ -27,15 +28,13 @@ function deploy(userSpecifiedRoot: string, options: any) { // Choose to build or not build depending on --no-build flag // We cannot chain generate and deploy while calling generate conditionally, so we split with if-else const site = new Site(rootFolder, outputFolder, '', undefined, options.siteConfig, - false, false, () => {}); + false, false, () => { }); if (options.build) { site.generate(undefined) .then(() => { logger.info('Build success!'); site.deploy(options.ci) - .then(depUrl => (depUrl !== null ? logger.info( - `The website has been deployed at: ${depUrl}`) - : logger.info('Deployed!'))); + .then(logDeployResult); }) .catch((error) => { logger.error(error.message); @@ -43,9 +42,7 @@ function deploy(userSpecifiedRoot: string, options: any) { }); } else { site.deploy(options.ci) - .then(depUrl => (depUrl !== null ? logger.info( - `The website has been deployed at: ${depUrl}`) - : logger.info('Deployed!'))) + .then(logDeployResult) .catch((error) => { logger.error(error.message); process.exitCode = 1; diff --git a/packages/cli/src/util/deploy.ts b/packages/cli/src/util/deploy.ts new file mode 100644 index 0000000000..fd2a3f1207 --- /dev/null +++ b/packages/cli/src/util/deploy.ts @@ -0,0 +1,14 @@ +import type { DeployResult } from '@markbind/core'; +import * as logger from './logger.js'; + +export function logDeployResult(result: DeployResult) { + if (result.ghActionsUrl) { + logger.info(`GitHub Actions deployment initiated. Check status at: ${result.ghActionsUrl}`); + } + if (result.ghPagesUrl) { + logger.info(`The website will be deployed at: ${result.ghPagesUrl}`); + } + if (!result.ghActionsUrl && !result.ghPagesUrl) { + logger.info('Deployed!'); + } +} diff --git a/packages/core/index.ts b/packages/core/index.ts index fdcba93a6d..94a1c4c5a4 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -2,3 +2,4 @@ import { Site } from './src/Site'; import { Template } from './src/Site/template'; export { Site, Template }; +export type { DeployResult } from './src/Site/SiteDeployManager'; diff --git a/packages/core/src/Site/SiteDeployManager.ts b/packages/core/src/Site/SiteDeployManager.ts index 524d756fdc..3806fdd600 100644 --- a/packages/core/src/Site/SiteDeployManager.ts +++ b/packages/core/src/Site/SiteDeployManager.ts @@ -15,6 +15,16 @@ export type DeployOptions = { user?: { name: string; email: string; }, }; +export type DeployResult = { + ghPagesUrl: string | null, + ghActionsUrl: string | null, +}; + +type ParsedGitHubRepo = { + name: string, + repoName: string, +}; + /** * Handles the deployment of the generated site to GitHub Pages or other configured remote repositories. */ @@ -40,7 +50,7 @@ export class SiteDeployManager { } /** - * Helper function for deploy(). Returns the ghpages link where the repo will be hosted. + * Helper function for deploy(). Returns the deployment URLs (GitHub Pages and GitHub Actions). */ async generateDepUrl(ciTokenVar: boolean | string, defaultDeployConfig: DeployOptions) { if (!this.siteConfig) { @@ -144,7 +154,7 @@ export class SiteDeployManager { const repoSlugMatch = repoSlugRegex.exec(repo); if (!repoSlugMatch) { throw new Error('-c/--ci expects a GitHub repository.\n' - + `The specified repository ${repo} is not valid.`); + + `The specified repository ${repo} is not valid.`); } const [, repoSlug] = repoSlugMatch; return repoSlug; @@ -159,35 +169,54 @@ export class SiteDeployManager { } /** - * Gets the deployed website's url, returning null if there was an error retrieving it. + * Parses a GitHub remote URL (HTTPS or SSH) and extracts the owner name and repo name. + * Returns null if the URL format is not recognized. */ - static async getDeploymentUrl(git: SimpleGit, options: DeployOptions) { + static parseGitHubRemoteUrl(remoteUrl: string): ParsedGitHubRepo | null { const HTTPS_PREAMBLE = 'https://'; const SSH_PREAMBLE = 'git@github.com:'; - const GITHUB_IO_PART = 'github.io'; - // https://.github.io// - function constructGhPagesUrl(remoteUrl: string) { - if (!remoteUrl) { - return null; - } - const parts = remoteUrl.split('/'); - if (remoteUrl.startsWith(HTTPS_PREAMBLE)) { - // https://github.com//.git (HTTPS) - const repoNameWithExt = parts[parts.length - 1]; - const repoName = repoNameWithExt.substring(0, repoNameWithExt.lastIndexOf('.')); - const name = parts[parts.length - 2].toLowerCase(); - return `https://${name}.${GITHUB_IO_PART}/${repoName}`; - } else if (remoteUrl.startsWith(SSH_PREAMBLE)) { - // git@github.com:/.git (SSH) - const repoNameWithExt = parts[parts.length - 1]; - const repoName = repoNameWithExt.substring(0, repoNameWithExt.lastIndexOf('.')); - const name = parts[0].substring(SSH_PREAMBLE.length); - return `https://${name}.${GITHUB_IO_PART}/${repoName}`; - } + if (!remoteUrl) { return null; } + const parts = remoteUrl.split('/'); + if (remoteUrl.startsWith(HTTPS_PREAMBLE)) { + // https://github.com//.git (HTTPS) + const repoNameWithExt = parts[parts.length - 1]; + const repoName = repoNameWithExt.substring(0, repoNameWithExt.lastIndexOf('.')); + const name = parts[parts.length - 2].toLowerCase(); + return { name, repoName }; + } else if (remoteUrl.startsWith(SSH_PREAMBLE)) { + // git@github.com:/.git (SSH) + const repoNameWithExt = parts[parts.length - 1]; + const repoName = repoNameWithExt.substring(0, repoNameWithExt.lastIndexOf('.')); + const name = parts[0].substring(SSH_PREAMBLE.length); + return { name, repoName }; + } + return null; + } + + /** + * Constructs the GitHub Pages URL from a remote URL. + * Returns a URL in the format: https://.github.io/ + */ + static constructGhPagesUrl(repo: ParsedGitHubRepo): string { + return `https://${repo.name}.github.io/${repo.repoName}`; + } + /** + * Constructs the GitHub Actions URL from a remote URL. + * Returns a URL in the format: https://github.com///actions + */ + static constructGhActionsUrl(repo: ParsedGitHubRepo): string { + return `https://github.com/${repo.name}/${repo.repoName}/actions`; + } + + /** + * Gets the deployed website's url and GitHub Actions url, + * returning null for either if there was an error retrieving it. + */ + static async getDeploymentUrl(git: SimpleGit, options: DeployOptions): Promise { const { remote, branch, repo } = options; const cnamePromise = gitUtil.getRemoteBranchFile(git, 'blob', remote, branch, 'CNAME'); const remoteUrlPromise = gitUtil.getRemoteUrl(git, remote); @@ -195,21 +224,25 @@ export class SiteDeployManager { try { const promiseResults: string[] = await Promise.all(promises) as string[]; - const generateGhPagesUrl = (results: string[]) => { - const cname = results[0]; - const remoteUrl = results[1]; - if (cname) { - return cname.trim(); - } else if (repo) { - return constructGhPagesUrl(repo); - } - return constructGhPagesUrl(remoteUrl.trim()); - }; - - return generateGhPagesUrl(promiseResults); + const cname = promiseResults[0]; + const remoteUrl = promiseResults[1]; + + const effectiveRemoteUrl = repo || (remoteUrl ? remoteUrl.trim() : ''); + + const parsedRepo = SiteDeployManager.parseGitHubRemoteUrl(effectiveRemoteUrl); + let ghPagesUrl: string | null; + if (cname) { + ghPagesUrl = cname.trim(); + } else { + ghPagesUrl = parsedRepo ? SiteDeployManager.constructGhPagesUrl(parsedRepo) : null; + } + + const ghActionsUrl = parsedRepo ? SiteDeployManager.constructGhActionsUrl(parsedRepo) : null; + + return { ghPagesUrl, ghActionsUrl }; } catch (err) { logger.error(err); - return null; + return { ghPagesUrl: null, ghActionsUrl: null }; } } } diff --git a/packages/core/test/unit/Site/SiteDeployManager.test.ts b/packages/core/test/unit/Site/SiteDeployManager.test.ts index 11a37e4c1e..85c9ba6c8e 100644 --- a/packages/core/test/unit/Site/SiteDeployManager.test.ts +++ b/packages/core/test/unit/Site/SiteDeployManager.test.ts @@ -2,6 +2,7 @@ import fs from 'fs-extra'; import { SiteDeployManager, DeployOptions } from '../../../src/Site/SiteDeployManager'; import { SiteConfig } from '../../../src/Site/SiteConfig'; import { SITE_JSON_DEFAULT } from '../utils/data'; +import * as gitUtil from '../../../src/utils/git'; const mockFs = fs as any; @@ -287,3 +288,80 @@ describe('Site deploy with various CI environments', () => { + `The specified repository ${invalidRepoConfig.deploy.repo} is not valid.`)); }); }); + +describe('SiteDeployManager URL construction utilities', () => { + describe('parseGitHubRemoteUrl', () => { + test('parses HTTPS remote URL correctly', () => { + const result = SiteDeployManager.parseGitHubRemoteUrl('https://github.com/UserName/my-repo.git'); + expect(result).toEqual({ name: 'username', repoName: 'my-repo' }); + }); + + test('parses SSH remote URL correctly', () => { + const result = SiteDeployManager.parseGitHubRemoteUrl('git@github.com:UserName/my-repo.git'); + expect(result).toEqual({ name: 'UserName', repoName: 'my-repo' }); + }); + + test('returns null for empty string', () => { + expect(SiteDeployManager.parseGitHubRemoteUrl('')).toBeNull(); + }); + + test('returns null for unrecognized URL format', () => { + expect(SiteDeployManager.parseGitHubRemoteUrl('ftp://example.com/repo.git')).toBeNull(); + }); + }); + + describe('constructGhPagesUrl', () => { + test('constructs GitHub Pages URL correctly', () => { + const result = SiteDeployManager.constructGhPagesUrl({ name: 'user', repoName: 'repo' }); + expect(result).toEqual('https://user.github.io/repo'); + }); + }); + + describe('constructGhActionsUrl', () => { + test('constructs GitHub Actions URL correctly', () => { + const result = SiteDeployManager.constructGhActionsUrl({ name: 'user', repoName: 'repo' }); + expect(result).toEqual('https://github.com/user/repo/actions'); + }); + }); + + describe('getDeploymentUrl', () => { + test('returns both ghPagesUrl and ghActionsUrl', async () => { + const result = await SiteDeployManager.getDeploymentUrl({} as any, { + remote: 'origin', + branch: 'gh-pages', + repo: '', + message: '', + }); + expect(result).toEqual({ + ghPagesUrl: 'https://mock-user.github.io/mock-repo', + ghActionsUrl: 'https://github.com/mock-user/mock-repo/actions', + }); + }); + + test('uses CNAME for ghPagesUrl when available', async () => { + jest.mocked(gitUtil.getRemoteBranchFile).mockResolvedValueOnce('custom.domain.com'); + + const result = await SiteDeployManager.getDeploymentUrl({} as any, { + remote: 'origin', + branch: 'gh-pages', + repo: '', + message: '', + }); + expect(result.ghPagesUrl).toEqual('custom.domain.com'); + expect(result.ghActionsUrl).toEqual('https://github.com/mock-user/mock-repo/actions'); + }); + + test('uses repo option over remote URL when specified', async () => { + const result = await SiteDeployManager.getDeploymentUrl({} as any, { + remote: 'origin', + branch: 'gh-pages', + repo: 'https://github.com/other-user/other-repo.git', + message: '', + }); + expect(result).toEqual({ + ghPagesUrl: 'https://other-user.github.io/other-repo', + ghActionsUrl: 'https://github.com/other-user/other-repo/actions', + }); + }); + }); +});