This document describes the GitHub Pages deployment setup for CubeHill, including SvelteKit static adapter configuration, base path handling, prerendering, and the GitHub Actions workflow.
CubeHill is deployed as a fully static site to GitHub Pages. The build pipeline:
- SvelteKit builds the app using
adapter-static - Vite produces optimized static assets in the
build/directory - GitHub Actions deploys
build/to GitHub Pages on push tomain - The site is served at
https://<username>.github.io/cubehill/
There is no server — all algorithm data is bundled at build time, and all pages are prerendered to static HTML.
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const dev = process.argv.includes('dev');
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: '404.html',
precompress: false,
strict: true,
}),
paths: {
base: dev ? '' : '/cubehill',
},
},
};
export default config;Note: trailingSlash is configured as a route-level export in src/routes/+layout.ts, not in the kit config (SvelteKit 2.x moved this to a page/layout option):
// src/routes/+layout.ts
export const prerender = true;
export const trailingSlash = 'always';Produces a build/ directory of static HTML, CSS, JS, and assets. No Node.js server is needed at runtime.
pages/assets: Both point tobuild/— HTML pages and static assets go to the same directoryfallback: '404.html': Generates a 404 page for unmatched routes. GitHub Pages serves this automatically for unknown paths.strict: true: Fails the build if any page was not prerendered. This catches missingentries()definitions for dynamic routes.
GitHub Pages serves project sites at a subpath: https://<username>.github.io/cubehill/. This means every URL, asset reference, and navigation call must be prefixed with /cubehill.
- In production (
npm run build):baseis set to'/cubehill' - In development (
npm run dev):baseis set to''(empty string) so the dev server works atlocalhost:5173/
The dev flag is detected via process.argv.includes('dev').
Forces all routes to end with a trailing slash (e.g., /cubehill/oll/ not /cubehill/oll). This is required for static file hosting because:
/cubehill/oll/maps to/cubehill/oll/index.html— a real file the server can find/cubehill/ollwould need server-side rewrite rules that GitHub Pages doesn't support
Without this setting, navigating directly to a route (or refreshing the page) may return a 404 on GitHub Pages.
In SvelteKit 2.x, trailingSlash is a route-level export rather than a kit config option. It is exported from src/routes/+layout.ts so it applies to all routes.
Every internal link and programmatic navigation must use the base path.
This is the most common source of deployment bugs. Links that work in development (href="/oll/") break in production because the actual URL is /cubehill/oll/.
<script>
import { base } from '$app/paths';
</script>
<a href="{base}/oll/">OLL Algorithms</a>
<a href="{base}/pll/{algo.id}/">{algo.name}</a>import { goto } from '$app/navigation';
import { base } from '$app/paths';
goto(`${base}/oll/${id}/`);Every handler in the ninja-keys command data must use base:
{
id: 'oll-1',
title: 'OLL 1',
handler: () => goto(`${base}/oll/oll-1/`),
}| Mistake | Correct |
|---|---|
href="/oll/" |
href="{base}/oll/" |
goto('/pll/pll-aa/') |
goto(\${base}/pll/pll-aa/`)` |
<img src="/logo.png"> |
<img src="{base}/logo.png"> or put it in static/ |
Static assets in the static/ folder are automatically served with the correct base path by SvelteKit — but only when referenced through SvelteKit's asset handling, not via hardcoded absolute paths.
SvelteKit's static adapter needs to know which dynamic routes exist at build time. For routes like /oll/[id]/, the adapter cannot discover valid id values automatically.
Each dynamic route must export an entries() function:
// src/routes/oll/[id]/+page.ts
import { ollCases } from '$lib/data/oll';
export function entries() {
return ollCases.map((c) => ({ id: c.id }));
}
export const prerender = true;// src/routes/pll/[id]/+page.ts
import { pllCases } from '$lib/data/pll';
export function entries() {
return pllCases.map((c) => ({ id: c.id }));
}
export const prerender = true;If entries() is missing and strict: true is set in the adapter config, the build fails with an error like:
Error: The following routes were marked as prerenderable but were not prerendered:
/oll/[id]
If strict: false, the build succeeds silently but the dynamic pages simply don't exist — users get 404s.
The root layout enables prerendering and trailing slashes globally:
// src/routes/+layout.ts
export const prerender = true;
export const trailingSlash = 'always';This tells SvelteKit to prerender all pages and append trailing slashes to all routes. Individual pages inherit these settings.
GitHub Pages runs Jekyll by default, which ignores files and directories starting with _ (underscore). SvelteKit/Vite produces assets in a _app/ directory, so without the .nojekyll file, these assets would be invisible to the web server.
The file is at static/.nojekyll (empty file, no content needed). SvelteKit copies everything in static/ to the build output root, so it ends up at build/.nojekyll, which GitHub Pages respects.
If this file is removed: the deployed site loads the HTML but all JavaScript, CSS, and assets fail to load — the page appears broken with no interactivity or styling.
The deployment is automated via .github/workflows/deploy.yml. The workflow has three jobs: test, build, and deploy, chained with needs dependencies so a broken build is never deployed.
name: Deploy to GitHub Pages
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: 'pages'
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
- run: npx playwright install --with-deps
- run: npm run test:e2e
build:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: 'build'
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4- Trigger: Runs on every push to
main - Permissions: Requires
pages: writeandid-token: writefor the new GitHub Pages deployment API - Concurrency: Only one deployment runs at a time; queued deployments are not cancelled (to avoid partial deploys)
- Test job: Runs lint, unit tests, build, and E2E tests. Gates the build job.
- Build job: Checks out code, installs dependencies with
npm ci(deterministic), runsnpm run build - Upload artifact: Uploads the
build/directory as a GitHub Pages artifact - Deploy job: Deploys the artifact to GitHub Pages using the official
deploy-pagesaction
For this workflow to function, the repository must be configured:
- Go to Settings → Pages
- Under Source, select GitHub Actions (not "Deploy from a branch")
- The workflow handles the rest — no branch or folder selection needed
The github-pages environment is created automatically. Optional: add environment protection rules (e.g., require approval before deploy) in Settings → Environments.
The project uses two separate CI triggers with different purposes:
| Trigger | What Runs | Purpose |
|---|---|---|
Pull request to main |
Lint, unit tests, build | Gate: must all pass before merge |
Push to main |
Lint, unit tests, build, E2E tests, deploy | Full pipeline including deployment |
PRs run a fast feedback loop (lint + test + build). The full E2E suite and deployment only run after merge to main, since E2E tests are slower and deployment should only happen from a known-good main branch.
flowchart LR
subgraph PR["PR to main"]
direction LR
PR1([PR]) --> L1[Lint] --> T1[Test] --> B1[Build] --> G1{Gate}
end
subgraph Push["Push to main"]
direction LR
P1([Push]) --> L2[Lint] --> T2[Test] --> B2[Build] --> E2[E2E] --> D2[Deploy]
end
Every pull request must pass these checks before merge:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run lint # ESLint
- run: npm run format:check # Prettier (check only, don't fix)
- run: npm test # Vitest unit tests
- run: npm run build # Build must succeed (catches type errors, missing entries())If any step fails, the PR cannot be merged.
The deploy workflow (documented above) includes a test job that gates the build and deploy jobs. The test job runs lint, unit tests, builds the project, and then runs E2E tests against the production build. A broken build is never deployed.
Configure in Settings → Branches → Branch protection rules for main:
- Require pull request before merging: No direct pushes to main (except for initial setup)
- Require status checks to pass: Select the
checkjob from the CI workflow - Require branches to be up to date: Ensures PRs are rebased on latest main before merge
- Do not allow bypassing: Even admins must follow the process
Each beads issue maps to a feature branch and (usually) one PR:
cubehill-xyz → branch: cubehill-xyz → PR: "Title from issue" → merge to main
Conventions:
- Branch names match the beads issue ID:
cubehill-xyz - PR title matches the issue title
- Close the beads issue when the PR is merged (not when the PR is opened)
- If a PR addresses multiple issues, list all IDs in the PR description and close them all on merge
- If an issue requires no code change (e.g., a docs-only update pushed directly), close the issue after the push
To test the production build locally before deploying:
npm run build # Build with production base path (/cubehill)
npm run preview # Serve the build/ directory locallyNote: npm run preview serves from the root, so links will point to /cubehill/... which won't resolve locally. To verify routing, either:
- Temporarily set
paths.baseto''and rebuild - Or use a local server that mounts
build/at the/cubehill/path
After npm run build, the build/ directory will contain (target structure with all routes implemented):
build/
├── .nojekyll # Copied from static/.nojekyll
├── index.html # Home page
├── 404.html # Fallback 404 page
├── oll/
│ ├── index.html # OLL listing page
│ ├── oll-1/index.html # OLL case 1
│ ├── oll-2/index.html # OLL case 2
│ └── ... # All 57 OLL cases
├── pll/
│ ├── index.html # PLL listing page
│ ├── pll-aa/index.html # Aa Perm
│ └── ... # All 21 PLL cases
└── _app/
├── immutable/ # Hashed JS/CSS chunks (long-cached)
└── version.json # Build version metadata
Each route becomes a directory with an index.html file, which is why trailingSlash: 'always' is required — it ensures SvelteKit generates this directory-based structure.