diff --git a/packages/cli/src/cmd/deploy.ts b/packages/cli/src/cmd/deploy.ts index 4ededc67d4..8281fbb6c1 100755 --- a/packages/cli/src/cmd/deploy.ts +++ b/packages/cli/src/cmd/deploy.ts @@ -1,9 +1,21 @@ import path from 'path'; -import { Site } from '@markbind/core'; +import { DeployResult, Site } from '@markbind/core'; import _ from 'lodash'; import * as cliUtil from '../util/cliUtil.js'; import * as logger from '../util/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!'); + } +} + function deploy(userSpecifiedRoot: string, options: any) { let rootFolder; try { @@ -33,9 +45,7 @@ function deploy(userSpecifiedRoot: string, options: any) { .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 +53,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/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..0f48fff0d5 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 = { + owner: 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) { @@ -140,14 +150,14 @@ export class SiteDeployManager { if (!repo) { return ciRepoSlug; } - const repoSlugRegex = /github\.com[:/]([\w-]+\/[\w-.]+)\.git$/; - const repoSlugMatch = repoSlugRegex.exec(repo); - if (!repoSlugMatch) { + + const parsed = SiteDeployManager.parseGitHubRemoteUrl(repo); + if (!parsed) { 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; + + return `${parsed.owner}/${parsed.repoName}`; } /** @@ -159,35 +169,56 @@ 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) { - const HTTPS_PREAMBLE = 'https://'; + static parseGitHubRemoteUrl(remoteUrl: string): ParsedGitHubRepo | null { + const HTTPS_PREAMBLE = 'https://github.com'; 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('/'); + + // get repo name + const repoNameWithExt = parts[parts.length - 1]; + const dotIndex = repoNameWithExt.lastIndexOf('.'); + const repoName = dotIndex === -1 ? repoNameWithExt : repoNameWithExt.substring(0, dotIndex); + + if (remoteUrl.startsWith(HTTPS_PREAMBLE)) { + // https://github.com//.git (HTTPS) + const owner = parts[parts.length - 2]; + return { owner, repoName }; + } else if (remoteUrl.startsWith(SSH_PREAMBLE)) { + // git@github.com:/.git (SSH) + const owner = parts[0].substring(SSH_PREAMBLE.length); + return { owner, repoName }; + } + return null; + } + + /** + * Constructs the GitHub Pages URL from a parsed remote URL. + * Returns a URL in the format: https://.github.io/ + */ + static constructGhPagesUrl(repo: ParsedGitHubRepo): string { + return `https://${repo.owner}.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.owner}/${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 +226,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..d11167ef85 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({ owner: 'UserName', repoName: 'my-repo' }); + }); + + test('parses SSH remote URL correctly', () => { + const result = SiteDeployManager.parseGitHubRemoteUrl('git@github.com:UserName/my-repo.git'); + expect(result).toEqual({ owner: '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({ owner: 'user', repoName: 'repo' }); + expect(result).toEqual('https://user.github.io/repo'); + }); + }); + + describe('constructGhActionsUrl', () => { + test('constructs GitHub Actions URL correctly', () => { + const result = SiteDeployManager.constructGhActionsUrl({ owner: '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', + }); + }); + }); +});