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
19 changes: 8 additions & 11 deletions packages/cli/src/cmd/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}`);
}
Expand All @@ -27,25 +28,21 @@ 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);
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
14 changes: 14 additions & 0 deletions packages/cli/src/util/deploy.ts
Original file line number Diff line number Diff line change
@@ -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}`);
Comment on lines +4 to +9
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ghPagesUrl can be a bare domain from CNAME (e.g. custom.domain.com), which is not a URL without a scheme. To match the intent of logging a deployed URL, consider normalizing ghPagesUrl for display (e.g. prefixing https:// when no scheme is present).

Suggested change
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}`);
function normalizeUrlForDisplay(url: string): string {
// If the URL already has a scheme (e.g. http://, https://), leave it as is.
if (/^[a-zA-Z][\w+.-]*:\/\//.test(url)) {
return url;
}
// Assume HTTPS for bare domains such as those from GitHub Pages CNAME.
return `https://${url}`;
}
export function logDeployResult(result: DeployResult) {
if (result.ghActionsUrl) {
logger.info(`GitHub Actions deployment initiated. Check status at: ${result.ghActionsUrl}`);
}
if (result.ghPagesUrl) {
const ghPagesDisplayUrl = normalizeUrlForDisplay(result.ghPagesUrl);
logger.info(`The website will be deployed at: ${ghPagesDisplayUrl}`);

Copilot uses AI. Check for mistakes.
}
if (!result.ghActionsUrl && !result.ghPagesUrl) {
logger.info('Deployed!');
}
}
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';
107 changes: 70 additions & 37 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 = {
name: 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 @@ -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;
Expand All @@ -159,57 +169,80 @@ 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://<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('/');
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 { name, repoName };
Comment on lines +175 to +188
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseGitHubRemoteUrl treats any https://.../<owner>/<repo> URL as GitHub (it doesn’t verify the host is github.com). Since MarkBind supports deploying to non-GitHub remotes, this can generate a bogus GitHub Actions link (and GH Pages link) for e.g. GitLab/Bitbucket HTTPS remotes. Consider validating the hostname/path (e.g. https://github.com/...) before returning a parsed result.

Copilot uses AI. Check for mistakes.
} 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('.'));
Comment on lines +184 to +192
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue in the SSH branch: using substring(0, lastIndexOf('.')) can yield an empty repoName for remotes like git@github.com:user/repo (no .git), or truncate dotted repo names. Prefer removing only a trailing .git (if present) and otherwise using the full last segment.

Suggested change
// 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 { name, 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('.'));
// https://github.com/<name|org>/<repo>[.git] (HTTPS)
const repoNameWithExt = parts[parts.length - 1];
const repoName = repoNameWithExt.toLowerCase().endsWith('.git')
? repoNameWithExt.slice(0, -4)
: repoNameWithExt;
const name = parts[parts.length - 2].toLowerCase();
return { name, repoName };
} else if (remoteUrl.startsWith(SSH_PREAMBLE)) {
// git@github.com:<name|org>/<repo>[.git] (SSH)
const repoNameWithExt = parts[parts.length - 1];
const repoName = repoNameWithExt.toLowerCase().endsWith('.git')
? repoNameWithExt.slice(0, -4)
: repoNameWithExt;

Copilot uses AI. Check for mistakes.
const name = parts[0].substring(SSH_PREAMBLE.length);
Comment on lines +184 to +193
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseGitHubRemoteUrl derives repoName via substring(0, lastIndexOf('.')), which becomes an empty string when the remote URL does not end with a file extension (e.g. https://github.com/user/repo). It can also truncate repo names containing dots when .git is absent. Consider stripping a trailing .git only when present (otherwise keep the full last path segment) so URL construction remains correct for common remote formats.

Suggested change
// 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 { name, 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);
// https://github.com/<name|org>/<repo>[.git] (HTTPS)
const repoSegment = parts[parts.length - 1] || '';
const repoName = repoSegment.endsWith('.git')
? repoSegment.substring(0, repoSegment.length - 4)
: repoSegment;
const name = (parts[parts.length - 2] || '').toLowerCase();
if (!name || !repoName) {
return null;
}
return { name, repoName };
} else if (remoteUrl.startsWith(SSH_PREAMBLE)) {
// git@github.com:<name|org>/<repo>[.git] (SSH)
const lastPart = parts[parts.length - 1] || '';
const repoName = lastPart.endsWith('.git')
? lastPart.substring(0, lastPart.length - 4)
: lastPart;
const ownerAndHost = parts[0] || '';
const name = ownerAndHost.substring(SSH_PREAMBLE.length).toLowerCase();
if (!name || !repoName) {
return null;
}

Copilot uses AI. Check for mistakes.
return { name, repoName };
}
return null;
}

/**
* Constructs the GitHub Pages URL from a remote URL.
* Returns a URL in the format: https://<name>.github.io/<repo>
*/
static constructGhPagesUrl(repo: ParsedGitHubRepo): string {
return `https://${repo.name}.github.io/${repo.repoName}`;
Comment on lines +199 to +204
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring says “Constructs the GitHub Pages URL from a remote URL”, but this function takes a parsed { name, repoName } object, not a URL. Consider updating the comment to avoid confusion about expected inputs.

Copilot uses AI. Check for mistakes.
}

/**
* 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.name}/${repo.repoName}/actions`;
Comment on lines +207 to +212
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring says “Constructs the GitHub Actions URL from a remote URL”, but this function takes a parsed { name, repoName } object, not a URL. Consider updating the comment to reflect the actual input type.

Copilot uses AI. Check for mistakes.
}

/**
* 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({ 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',
});
});
});
});