Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions packages/cli/src/cmd/deploy.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -33,19 +45,15 @@ 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);
process.exitCode = 1;
});
} 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;
Expand Down
1 change: 1 addition & 0 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
121 changes: 78 additions & 43 deletions packages/core/src/Site/SiteDeployManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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) {
Expand Down Expand Up @@ -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}`;
}

/**
Expand All @@ -159,57 +169,82 @@ 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://<name|org name>.github.io/<repo name>/
function constructGhPagesUrl(remoteUrl: string) {
if (!remoteUrl) {
return null;
}
const parts = remoteUrl.split('/');
if (remoteUrl.startsWith(HTTPS_PREAMBLE)) {
// https://github.com/<name|org>/<repo>.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:<name|org>/<repo>.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/<name|org>/<repo>.git (HTTPS)
const owner = parts[parts.length - 2];
return { owner, repoName };
} else if (remoteUrl.startsWith(SSH_PREAMBLE)) {
// git@github.com:<name|org>/<repo>.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://<name>.github.io/<repo>
*/
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/<name>/<repo>/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<DeployResult> {
const { remote, branch, repo } = options;
const cnamePromise = gitUtil.getRemoteBranchFile(git, 'blob', remote, branch, 'CNAME');
const remoteUrlPromise = gitUtil.getRemoteUrl(git, remote);
const promises = [cnamePromise, remoteUrlPromise];

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 };
}
}
}
78 changes: 78 additions & 0 deletions packages/core/test/unit/Site/SiteDeployManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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',
});
});
});
});
Loading