Skip to content

Commit bc6519a

Browse files
committed
feat: Add base path option for GitHub Pages deployment
- Introduced a new CLI option `--base` to specify the public URL path for assets. - Updated deployment logic to handle base path for GitHub Pages. - Added utility functions to normalize base paths and determine public paths based on repository type. - Enhanced tests to cover new functionality and ensure correct behavior for different repository setups.
1 parent bde37b3 commit bc6519a

File tree

4 files changed

+178
-24
lines changed

4 files changed

+178
-24
lines changed

package-lock.json

Lines changed: 0 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/__tests__/gh-pages.test.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ jest.mock('fs-extra', () => ({
33
default: {
44
pathExists: jest.fn(),
55
readJson: jest.fn(),
6+
copy: jest.fn(),
67
},
78
}));
89

@@ -26,10 +27,15 @@ import path from 'path';
2627
import fs from 'fs-extra';
2728
import { execa } from 'execa';
2829
import ghPages from 'gh-pages';
29-
import { runDeployToGitHubPages } from '../gh-pages.js';
30+
import {
31+
getGitHubPagesPublicPath,
32+
normalizeDeployBasePath,
33+
runDeployToGitHubPages,
34+
} from '../gh-pages.js';
3035

3136
const mockPathExists = fs.pathExists as jest.MockedFunction<typeof fs.pathExists>;
3237
const mockReadJson = fs.readJson as jest.MockedFunction<typeof fs.readJson>;
38+
const mockCopy = fs.copy as jest.MockedFunction<typeof fs.copy>;
3339
const mockExeca = execa as jest.MockedFunction<typeof execa>;
3440
const mockGhPagesPublish = ghPages.publish as jest.MockedFunction<typeof ghPages.publish>;
3541

@@ -38,7 +44,11 @@ const cwd = '/tmp/my-app';
3844
/** Setup pathExists to return values based on path (avoids brittle call-order chains) */
3945
function setupPathExists(checks: Record<string, boolean>) {
4046
mockPathExists.mockImplementation((p: string) => {
47+
if (p.endsWith(`${path.sep}404.html`) || p.endsWith('/404.html')) {
48+
return Promise.resolve(checks['404.html'] ?? false);
49+
}
4150
for (const [key, value] of Object.entries(checks)) {
51+
if (key === '404.html') continue;
4252
if (p.includes(key) || p === path.join(cwd, key)) return Promise.resolve(value);
4353
}
4454
return Promise.resolve(checks['*'] ?? false);
@@ -56,6 +66,22 @@ describe('runDeployToGitHubPages', () => {
5666
consoleLogSpy.mockRestore();
5767
});
5868

69+
describe('getGitHubPagesPublicPath / normalizeDeployBasePath', () => {
70+
it('uses root for user GitHub Pages repo', () => {
71+
expect(getGitHubPagesPublicPath('octocat', 'octocat.github.io')).toBe('/');
72+
});
73+
74+
it('uses /repo/ for project pages', () => {
75+
expect(getGitHubPagesPublicPath('octocat', 'Hello-World')).toBe('/Hello-World/');
76+
});
77+
78+
it('normalizes base path overrides', () => {
79+
expect(normalizeDeployBasePath('/')).toBe('/');
80+
expect(normalizeDeployBasePath('my-app')).toBe('/my-app/');
81+
expect(normalizeDeployBasePath('/my-app')).toBe('/my-app/');
82+
});
83+
});
84+
5985
it('throws when package.json does not exist', async () => {
6086
mockPathExists.mockImplementation((p: string) =>
6187
Promise.resolve(path.join(cwd, 'package.json') !== p)
@@ -108,14 +134,20 @@ describe('runDeployToGitHubPages', () => {
108134
exitCode: 0,
109135
} as Awaited<ReturnType<typeof execa>>);
110136
mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
137+
mockCopy.mockResolvedValue(undefined);
111138

112139
await runDeployToGitHubPages(cwd, { skipBuild: false });
113140

114141
expect(mockExeca).toHaveBeenCalledTimes(2); // git, npm run build
115142
expect(mockExeca).toHaveBeenNthCalledWith(2, 'npm', ['run', 'build'], {
116143
cwd,
117144
stdio: 'inherit',
145+
env: expect.objectContaining({ ASSET_PATH: '/repo/' }),
118146
});
147+
expect(mockCopy).toHaveBeenCalledWith(
148+
path.join(cwd, 'dist', 'index.html'),
149+
path.join(cwd, 'dist', '404.html')
150+
);
119151
expect(mockGhPagesPublish).toHaveBeenCalledWith(
120152
path.join(cwd, 'dist'),
121153
{ branch: 'gh-pages', repo: 'https://github.com/user/repo.git' },
@@ -140,15 +172,36 @@ describe('runDeployToGitHubPages', () => {
140172
exitCode: 0,
141173
} as Awaited<ReturnType<typeof execa>>);
142174
mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
175+
mockCopy.mockResolvedValue(undefined);
143176

144177
await runDeployToGitHubPages(cwd, { skipBuild: false });
145178

146-
expect(mockExeca).toHaveBeenNthCalledWith(2, 'yarn', ['build'], {
179+
expect(mockExeca).toHaveBeenNthCalledWith(2, 'yarn', ['run', 'build'], {
147180
cwd,
148181
stdio: 'inherit',
182+
env: expect.objectContaining({ ASSET_PATH: '/repo/' }),
149183
});
150184
});
151185

186+
it('does not set ASSET_PATH for <user>.github.io repository (site root)', async () => {
187+
setupPathExists({ 'package.json': true, 'dist': true });
188+
mockReadJson.mockResolvedValueOnce({
189+
scripts: { build: 'webpack --config webpack.prod.js' },
190+
});
191+
mockExeca.mockResolvedValue({
192+
stdout: 'https://github.com/octocat/octocat.github.io.git',
193+
stderr: '',
194+
exitCode: 0,
195+
} as Awaited<ReturnType<typeof execa>>);
196+
mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
197+
mockCopy.mockResolvedValue(undefined);
198+
199+
await runDeployToGitHubPages(cwd, { skipBuild: false });
200+
201+
const buildOpts = mockExeca.mock.calls[1][2] as { env?: NodeJS.ProcessEnv };
202+
expect(buildOpts.env?.ASSET_PATH).toBeUndefined();
203+
});
204+
152205
it('uses pnpm when pnpm-lock.yaml exists (and no yarn.lock)', async () => {
153206
setupPathExists({ 'package.json': true, 'pnpm-lock.yaml': true, 'dist': true });
154207
mockReadJson.mockResolvedValueOnce({
@@ -160,12 +213,14 @@ describe('runDeployToGitHubPages', () => {
160213
exitCode: 0,
161214
} as Awaited<ReturnType<typeof execa>>);
162215
mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
216+
mockCopy.mockResolvedValue(undefined);
163217

164218
await runDeployToGitHubPages(cwd, { skipBuild: false });
165219

166-
expect(mockExeca).toHaveBeenNthCalledWith(2, 'pnpm', ['build'], {
220+
expect(mockExeca).toHaveBeenNthCalledWith(2, 'pnpm', ['run', 'build', '--', '--base', '/repo/'], {
167221
cwd,
168222
stdio: 'inherit',
223+
env: expect.objectContaining({ ASSET_PATH: '/repo/' }),
169224
});
170225
});
171226

@@ -177,6 +232,7 @@ describe('runDeployToGitHubPages', () => {
177232
exitCode: 0,
178233
} as Awaited<ReturnType<typeof execa>>);
179234
mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
235+
mockCopy.mockResolvedValue(undefined);
180236

181237
await runDeployToGitHubPages(cwd, { skipBuild: true });
182238

@@ -228,6 +284,7 @@ describe('runDeployToGitHubPages', () => {
228284
exitCode: 0,
229285
} as Awaited<ReturnType<typeof execa>>);
230286
mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
287+
mockCopy.mockResolvedValue(undefined);
231288

232289
await runDeployToGitHubPages(cwd, {
233290
skipBuild: true,
@@ -271,6 +328,7 @@ describe('runDeployToGitHubPages', () => {
271328
stderr: '',
272329
exitCode: 0,
273330
} as Awaited<ReturnType<typeof execa>>);
331+
mockCopy.mockResolvedValue(undefined);
274332
mockGhPagesPublish.mockImplementation((_dir, _opts, cb) =>
275333
cb(new Error('Deploy failed'))
276334
);

src/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,18 @@ program
143143
.option('-d, --dist-dir <dir>', 'Build output directory to deploy', 'dist')
144144
.option('--no-build', 'Skip running the build step (deploy existing output only)')
145145
.option('-b, --branch <branch>', 'Git branch to deploy to', 'gh-pages')
146+
.option(
147+
'--base <path>',
148+
'Public URL path for assets (default: /<repo>/ from git origin, or / for <user>.github.io repos)'
149+
)
146150
.action(async (projectPath, options) => {
147151
const cwd = projectPath ? path.resolve(projectPath) : process.cwd();
148152
try {
149153
await runDeployToGitHubPages(cwd, {
150154
distDir: options.distDir,
151155
skipBuild: options.build === false,
152156
branch: options.branch,
157+
basePath: options.base,
153158
});
154159
} catch (error) {
155160
if (error instanceof Error) {

0 commit comments

Comments
 (0)