diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index a81936f..afb9561 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -2,7 +2,7 @@
Thanks for contributing! Quiz PRs are the common case — if this is a code or
docs PR instead, delete the checklist below and describe the change.
-Full walkthrough: https://a-dev.github.io/quizbun/docs/contributing/
+Full walkthrough: https://quizbun.fyi/docs/contributing/
-->
## What this quiz covers
@@ -11,9 +11,9 @@ Full walkthrough: https://a-dev.github.io/quizbun/docs/contributing/
## Checklist
-- [ ] The quiz validates on the [import page](https://a-dev.github.io/quizbun/import/) and I completed a full run of it there.
+- [ ] The quiz validates on the [import page](https://quizbun.fyi/import/) and I completed a full run of it there.
- [ ] `description` and `language` are set, and there is at least one tag.
- [ ] The file is `content/quizzes/{id}.json`, where `{id}` is the quiz's `id`.
- [ ] Every Explanation teaches the concept — none of them just restate the correct answer.
- [ ] Titles are in sentence case: `Agents and tool calling: what could go wrong?`, not `Agents and Tool Calling: What Could Go Wrong?`.
-- [ ] I understand that by submitting this quiz I license it under the repository's license ([details](https://a-dev.github.io/quizbun/docs/contributing/#licensing-what-submitting-means)).
+- [ ] I understand that by submitting this quiz I license it under the repository's license ([details](https://quizbun.fyi/docs/contributing/#licensing-what-submitting-means)).
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 56351e1..abba0a0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -84,8 +84,6 @@ jobs:
- name: Build static site
run: bun run build
- env:
- GITHUB_PAGES: "true"
- name: Check dist for base-path regressions
run: bash scripts/check-dist-base-paths.sh
diff --git a/AGENTS.md b/AGENTS.md
index 802ca48..be340f7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,7 +12,7 @@ Bun; Node >= 22.12. Scripts live in `package.json` — `dev`, `build`, `check` (
## Rules
-- GitHub Pages sets `GITHUB_PAGES=true` → Astro `base` is `/quizbun`. Never hardcode absolute site/asset paths.
+- GitHub Pages serves the custom domain `https://quizbun.fyi/` at Astro base `/`. Use `withBase` for site and asset paths.
- The Zod schema is the single source of truth for the Quiz Object Standard; `public/schema/quiz.v1.json` is generated from it. Unknown fields are errors. Validation messages are a product feature: path-precise and pasteable into an AI chat.
- The Standard carries no presentation fields. Option identity is JSON order; shuffling, labeling, and page size are Renderer behavior.
- Markdown goes through [markdown.ts](src/shared/lib/render/markdown.ts) (`marked` + `sanitize-html`); raw HTML is always stripped.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index df1ca6c..ed1216e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,14 +2,14 @@
The most valuable contribution is a quiz for the public Catalog. The complete walkthrough — generating a quiz with the published AI prompt, validating it locally, meeting the Public catalog profile, and reading CI feedback — lives in the published contributor guide:
-****
+****
(The same text is in this repo at [docs/contributing.md](docs/contributing.md) — the site page is rendered from it.)
The short version:
-1. Generate or write a quiz that satisfies [the Quiz Object Standard](https://a-dev.github.io/quizbun/docs/standard/).
-2. Validate it by pasting the JSON into the [import page](https://a-dev.github.io/quizbun/import/).
+1. Generate or write a quiz that satisfies [the Quiz Object Standard](https://quizbun.fyi/docs/standard/).
+2. Validate it by pasting the JSON into the [import page](https://quizbun.fyi/import/).
3. Add it as `content/quizzes/{id}.json` and open a pull request — one quiz (or one coherent cluster) per PR.
By submitting a public quiz, you license it under the repository's [MIT license](LICENSE); there is no per-quiz license field.
diff --git a/README.md b/README.md
index 435167b..f6a0ce0 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
Quizbun is a static, explanation-first quiz catalog built around the Quiz Object
Standard.
-[https://a-dev.github.io/quizbun/](https://a-dev.github.io/quizbun/)
+[https://quizbun.fyi/](https://quizbun.fyi/)
## Create quizzes with an AI agent
diff --git a/SPEC.md b/SPEC.md
index 5ad6838..d005771 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -143,7 +143,7 @@ Shuffling, Option labels, pagination, Page size, keyboard controls, and layout n
Quizbun uses Astro 7 static output and React 19 islands, with Bun as the package manager and Node 22.12 or newer. GitHub Pages hosts the build. JavaScript hydrates only interactive parts such as the player, Import, Library, Tag filter, continue block, and copy-prompt control.
-GitHub Pages builds set `GITHUB_PAGES=true`, which changes Astro's base path to `/quizbun`. Route and asset URLs must use `withBase` or the documentation loader's rewriting. Do not hardcode root-relative site URLs.
+GitHub Pages serves `https://quizbun.fyi/` with Astro base `/`. Route and asset URLs use `withBase` or the documentation loader's rewriting.
### Code layout
@@ -293,9 +293,9 @@ Vitest runs two projects under [vitest.config.ts](vitest.config.ts):
- Unit tests use `.spec.ts` in Node.
- Component tests use `.test.tsx` in real Chromium through `vitest-browser-react`.
-Playwright runs `.e2e.ts` journeys in [e2e](e2e) against a root-base `astro preview` build. Rebuild after a `GITHUB_PAGES=true` build because Playwright's base URL has no `/quizbun` prefix. Each test gets fresh browser storage. Catalog tests derive mutable content from the rendered page instead of hardcoding Quiz names.
+Playwright runs `.e2e.ts` journeys in [e2e](e2e) against a root-base `astro preview` build. Each test gets fresh browser storage. Catalog tests derive mutable content from the rendered page instead of hardcoding Quiz names.
-The covered journeys include Import through Summary, Catalog filtering, every Question type, resume, Library management, Page-size changes, Content-hash invalidation, media behavior, preview deep links, keyboard and phone use, theme behavior, install metadata, and storage notices. A deploy-fidelity test for base-path deep links remains open.
+The covered journeys include Import through Summary, Catalog filtering, every Question type, resume, Library management, Page-size changes, Content-hash invalidation, media behavior, preview deep links, keyboard and phone use, theme behavior, install metadata, and storage notices. A deploy-fidelity test covers root-path deep links.
Stryker runs mutation testing over the unit lane, driven by [stryker.config.mjs](stryker.config.mjs) and [vitest.stryker.config.ts](vitest.stryker.config.ts). It is a local tool for finding tests that execute code without asserting on it — `bun run test:mutation` for everything in scope, `bun run mutate:file ` for one file, `bun run mutate:report` to open the HTML report. It is deliberately outside CI: it is slow, its score is advisory, and `thresholds.break` is `null` so it never fails a command. Files the unit lane does not reach show up as "no coverage" rather than as survivors; they are exercised by the component and e2e lanes instead.
@@ -326,7 +326,7 @@ While the repository is private, CI runs only through `workflow_dispatch`. Resto
- App v1.1.0 added installation guidance and persistent browser storage for Library and Run durability.
- Standard v1.1 added Question Images and Videos across schema, docs, validation, assets, rendering, and tests without changing `schemaVersion`.
- Standard v1.2 added optional Image `width` and `height` across schema, docs, and the JSON Schema artifact, with `scripts/generate-quiz-image-sizes.ts` generating them. The pre-launch Catalog migration wrote dimensions for 743 Images across 726 Questions in 55 Quizzes. The Public catalog profile verifies every pair against its vendored file.
-- Local production and `/quizbun/` base-path builds pass. A live GitHub Pages acceptance run remains blocked while the repository is private.
+- The production build targets the custom domain at the root path. Live GitHub Pages acceptance follows the domain cutover.
When the repository becomes public, restore automatic CI, deploy `main`, publish the release, run the full acceptance flow on the live URL, and verify validation on a pull request from a fork.
diff --git a/astro.config.mjs b/astro.config.mjs
index 086d0d5..f8a805a 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -9,37 +9,26 @@ import { loadEnv } from "vite";
import { sharedViteConfig } from "./vite.shared.ts";
import { quizAssets } from "./astro-quiz-assets.ts";
-const githubPagesBase = "/quizbun";
-const githubPagesSite = `https://a-dev.github.io${githubPagesBase}`;
const devtoolsBuild = process.env.DEVTOOLS === "true";
-const { ALLOWED_HOSTS = "", GITHUB_PAGES } = loadEnv(
- process.env.NODE_ENV ?? "development",
- process.cwd(),
- "",
-);
+const { ALLOWED_HOSTS = "" } = loadEnv(process.env.NODE_ENV ?? "development", process.cwd(), "");
const allowedHosts = ALLOWED_HOSTS.split(",")
.map((host) => host.trim())
.filter(Boolean);
-const base = GITHUB_PAGES === "true" ? githubPagesBase : "/";
-// Base-relative, so the exclusion list stays the same in a root build and a
-// `GITHUB_PAGES=true` one. Duplicates (`/quizzes/page/1/` canonicals to
+// Duplicates (`/quizzes/page/1/` canonicals to
// `/quizzes/`) and the two device-local Library shells have nothing to index.
const excludedSitemapRoutes = new Set(["/library/", "/library/quiz/", "/quizzes/page/1/"]);
-const basePrefix = base === "/" ? "" : base;
/** @param {string} page */
function sitemapRoutePath(page) {
const pathname = new URL(page).pathname;
- return basePrefix !== "" && pathname.startsWith(`${basePrefix}/`)
- ? pathname.slice(basePrefix.length)
- : pathname;
+ return pathname;
}
export default defineConfig({
- base,
+ base: "/",
server: {
allowedHosts,
},
@@ -51,7 +40,7 @@ export default defineConfig({
quizAssets(),
],
output: "static",
- site: githubPagesSite,
+ site: "https://quizbun.fyi",
vite: {
...sharedViteConfig,
plugins: [
diff --git a/docs/contributing.md b/docs/contributing.md
index be92553..c297bb6 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -1,10 +1,10 @@
# Contributing a Quiz to the public catalog
-Submit a Quiz to Quizbun's [public catalog](https://a-dev.github.io/quizbun/quizzes) through a pull request. This guide covers how to create and validate it, meet the publishing rules, and respond to review feedback.
+Submit a quiz to Quizbun's [public catalog](https://quizbun.fyi/quizzes) through a pull request. This guide covers how to create and validate it, meet the publishing rules, and respond to review feedback.
## License
-By submitting a public Quiz, you license it under the repository's [MIT license](../LICENSE). Separate per-Quiz licenses are not supported or planned. If you do not want to use MIT, keep your Quiz private by importing it on the [import page](https://a-dev.github.io/quizbun/import/).
+By submitting a public quiz, you license it under the repository's [MIT license](../LICENSE). Separate per-Quiz licenses are not supported or planned. If you do not want to use MIT, keep your Quiz private by importing it on the [import page](https://quizbun.fyi/import/).
## Step 1: generate the Quiz
@@ -18,7 +18,7 @@ Both validators below use the same schema and error formatter. See [the error-me
### The import page
-Paste your Quiz JSON into the [import page](https://a-dev.github.io/quizbun/import/):
+Paste your Quiz JSON into the [import page](https://quizbun.fyi/import/):
- If it validates, save the Quiz to your Library. Complete a Run and read every Explanation as a Learner would.
- If it fails, the error report identifies the exact JSON path to fix.
diff --git a/e2e/smoke.e2e.ts b/e2e/smoke.e2e.ts
index 52e05eb..f61378c 100644
--- a/e2e/smoke.e2e.ts
+++ b/e2e/smoke.e2e.ts
@@ -7,3 +7,9 @@ test("the built site serves the home page", async ({ page }) => {
const response = await page.goto("/");
expect(response?.ok()).toBeTruthy();
});
+
+test("the built site serves a catalog deep link", async ({ page }) => {
+ const response = await page.goto("/quizzes/");
+ expect(response?.ok()).toBeTruthy();
+ await expect(page).toHaveURL(/\/quizzes\/$/);
+});
diff --git a/playwright.config.ts b/playwright.config.ts
index 024db9a..5ab107e 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -34,9 +34,7 @@ export default defineConfig({
// cross-engine issue surfaces.
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
// Run against the real static build served by `astro preview` — the faithful
- // production artifact, not the dev server. Base path stays "/" (GITHUB_PAGES
- // unset); base-path correctness is covered separately by
- // scripts/check-dist-base-paths.sh, so specs navigate with RELATIVE paths.
+ // production artifact, not the dev server. Specs navigate with root paths.
webServer: {
// Astro auto-backgrounds preview when it detects an AI agent. Tell it that
// backgrounding is already handled so Playwright can own the foreground
diff --git a/public/robots.txt b/public/robots.txt
index d3273c6..63ad423 100644
--- a/public/robots.txt
+++ b/public/robots.txt
@@ -1,4 +1,4 @@
User-agent: *
Allow: /
-Sitemap: https://a-dev.github.io/quizbun/sitemap-index.xml
\ No newline at end of file
+Sitemap: https://quizbun.fyi/sitemap-index.xml
\ No newline at end of file
diff --git a/scripts/check-dist-base-paths.sh b/scripts/check-dist-base-paths.sh
index 6e46353..49ac457 100755
--- a/scripts/check-dist-base-paths.sh
+++ b/scripts/check-dist-base-paths.sh
@@ -1,48 +1,36 @@
#!/usr/bin/env bash
-# Base-path guard: after a GITHUB_PAGES=true build, no internal link in
-# dist/ may be root-absolute outside the /quizbun base — such links 404 on
-# GitHub Pages. Scans href/src/srcset/action attributes in built HTML and URL
-# fields in the generated web app manifest.
+# Check the production artifact for legacy internal paths and canonical URLs.
set -euo pipefail
DIST_DIR="${1:-dist}"
-BASE_PATH="/quizbun"
if [[ ! -d "$DIST_DIR" ]]; then
- echo "error: '$DIST_DIR' not found — run 'GITHUB_PAGES=true bun run build' first" >&2
+ echo "error: '$DIST_DIR' not found — run 'bun run build' first" >&2
exit 1
fi
-# Root-absolute URLs ("/...") that don't start with the base path and aren't
-# protocol-relative ("//...").
-violations=$(grep -rEon "(href|src|srcset|action)=\"/[^\"]*\"" "$DIST_DIR" --include='*.html' |
- grep -Ev "=\"$BASE_PATH(/|\")" |
- grep -Ev "=\"//" || true)
-
-if [[ -n "$violations" ]]; then
- echo "Base-path regression: root-absolute internal URLs found outside '$BASE_PATH':" >&2
- echo "$violations" >&2
+if [[ ! -f "$DIST_DIR/manifest.webmanifest" || ! -f "$DIST_DIR/robots.txt" || ! -f "$DIST_DIR/sitemap-index.xml" ]]; then
+ echo "error: required manifest, robots.txt, or sitemap is missing from '$DIST_DIR'" >&2
exit 1
fi
-MANIFEST_FILE="$DIST_DIR/manifest.webmanifest"
-
-if [[ ! -f "$MANIFEST_FILE" ]]; then
- echo "error: '$MANIFEST_FILE' not found" >&2
+if find "$DIST_DIR" -type f \( -name '*.html' -o -name '*.xml' -o -name '*.txt' -o -name '*.webmanifest' -o -name '*.js' -o -name '*.css' \) \
+ -exec grep -nEH '"/quizbun/|url\(/quizbun/|https://a-dev\.github\.io/quizbun' {} + | grep .; then
+ echo "error: legacy /quizbun/ URL found in '$DIST_DIR'" >&2
exit 1
fi
-# Covers every URL-bearing field the manifest has today. Adding a field that
-# carries a URL (`shortcuts[].url`, `file_handlers[].action`, …) means adding it
-# to this alternation — a missed one is a silent blind spot, not a failure.
-manifest_violations=$(grep -Eon '"(id|scope|start_url|src)"[[:space:]]*:[[:space:]]*"/[^"]*"' "$MANIFEST_FILE" |
- grep -Ev "\"$BASE_PATH(/|\")" |
- grep -Ev '"//' || true)
+if ! grep -qE 'https://quizbun\.fyi/sitemap-0\.xml' "$DIST_DIR/sitemap-index.xml" ||
+ ! grep -qE 'Sitemap: https://quizbun\.fyi/sitemap-index\.xml' "$DIST_DIR/robots.txt"; then
+ echo "error: sitemap or robots.txt does not use the canonical domain" >&2
+ exit 1
+fi
-if [[ -n "$manifest_violations" ]]; then
- echo "Base-path regression: manifest URLs found outside '$BASE_PATH':" >&2
- echo "$manifest_violations" >&2
+if find "$DIST_DIR" -type f -name '*.html' \
+ -exec grep -nEHo '&2
exit 1
fi
-echo "OK: no root-absolute internal URLs outside '$BASE_PATH' in $DIST_DIR HTML or manifest"
+echo "OK: root-path artifact uses https://quizbun.fyi/"
diff --git a/scripts/full-check.sh b/scripts/full-check.sh
index e945e60..a118c61 100755
--- a/scripts/full-check.sh
+++ b/scripts/full-check.sh
@@ -33,7 +33,7 @@ STEPS=(
"quiz:sizes:check ci Check Quiz Image dimensions bun run quiz:sizes:check"
"schema:check ci Check JSON Schema drift bun run schema:check"
"skill:create-quiz:check ci Check create-quiz skill drift bun run skill:create-quiz:check"
- "build ci Build static site (GITHUB_PAGES=true) GITHUB_PAGES=true bun run build"
+ "build ci Build static site bun run build"
"dist:base-paths ci Check dist for base-path regressions bash scripts/check-dist-base-paths.sh"
"e2e ci E2E tests bun run e2e"
"knip extra Unused files, exports and dependencies bun run knip"
@@ -92,8 +92,8 @@ for step in "${STEPS[@]}"; do
continue
fi
- # The base-path guard only means anything against a fresh GITHUB_PAGES
- # build, so it goes with the build step — a stale dist/ would report
+ # The dist URL guard only means anything against a fresh build,
+ # so it goes with the build step — a stale dist/ would report
# failures that say nothing about the current tree.
if [[ "$id" == "dist:base-paths" ]] && [[ " ${passed[*]+"${passed[*]}"} " != *" build "* ]]; then
skipped+=("$id")
diff --git a/scripts/validate-public-quizzes.ts b/scripts/validate-public-quizzes.ts
index b5f11bf..0a1c52e 100644
--- a/scripts/validate-public-quizzes.ts
+++ b/scripts/validate-public-quizzes.ts
@@ -17,7 +17,7 @@ import { toPathLabel } from "./quiz-validation";
// this report — the round-trip section of the contributor guide.
const CONTRIBUTOR_GUIDE_HINT =
"\nHow to fix this (and how to paste the report back into an AI chat):\n" +
- "https://a-dev.github.io/quizbun/docs/contributing/#the-error-message-round-trip";
+ "https://quizbun.fyi/docs/contributing/#the-error-message-round-trip";
// Stays exactly as given: the loader dates Quizzes from `git log` output, whose
// paths are repo-relative, so an absolute directory would match nothing there.
diff --git a/src/shared/lib/docs/rewrite-links.spec.ts b/src/shared/lib/docs/rewrite-links.spec.ts
index 1790b88..fd9f15d 100644
--- a/src/shared/lib/docs/rewrite-links.spec.ts
+++ b/src/shared/lib/docs/rewrite-links.spec.ts
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { type DocLinkContext, GITHUB_REPO_URL, resolveDocLink } from "./rewrite-links";
const context = (overrides: Partial = {}): DocLinkContext => ({
- base: "/quizbun/",
+ base: "/",
fileExists: () => true,
sourceRepoPath: "docs/standard.md",
...overrides,
@@ -11,16 +11,16 @@ const context = (overrides: Partial = {}): DocLinkContext => ({
describe("resolveDocLink", () => {
it("rewrites links between published docs to site routes", () => {
- expect(resolveDocLink("./quiz-generation-page.md", context())).toBe("/quizbun/docs/prompt/");
+ expect(resolveDocLink("./quiz-generation-page.md", context())).toBe("/docs/prompt/");
expect(
resolveDocLink("../standard.md", context({ sourceRepoPath: "docs/examples/README.md" })),
- ).toBe("/quizbun/docs/standard/");
+ ).toBe("/docs/standard/");
});
it("preserves fragments on rewritten site links", () => {
expect(
resolveDocLink("./standard.md#markdown", context({ sourceRepoPath: "docs/contributing.md" })),
- ).toBe("/quizbun/docs/standard/#markdown");
+ ).toBe("/docs/standard/#markdown");
});
it("works without a base path prefix", () => {
@@ -33,7 +33,7 @@ describe("resolveDocLink", () => {
"../../public/schema/quiz.v1.json",
context({ sourceRepoPath: "docs/examples/README.md" }),
),
- ).toBe("/quizbun/schema/quiz.v1.json");
+ ).toBe("/schema/quiz.v1.json");
});
it("rewrites canonical example JSON files to their download routes", () => {
@@ -42,7 +42,7 @@ describe("resolveDocLink", () => {
"./public-quiz-single-choice.json",
context({ sourceRepoPath: "docs/examples/README.md" }),
),
- ).toBe("/quizbun/docs/examples/public-quiz-single-choice.json");
+ ).toBe("/docs/examples/public-quiz-single-choice.json");
});
it("points repo-only files at GitHub", () => {
@@ -52,9 +52,11 @@ describe("resolveDocLink", () => {
});
it("rewrites absolute live-site links to base-relative ones", () => {
- expect(resolveDocLink("https://a-dev.github.io/quizbun/import/", context())).toBe(
- "/quizbun/import/",
- );
+ expect(resolveDocLink("https://quizbun.fyi/import/", context())).toBe("/import/");
+ });
+
+ it("rewrites legacy site links to root paths", () => {
+ expect(resolveDocLink("https://a-dev.github.io/quizbun/import/", context())).toBe("/import/");
});
it("passes through external, mailto, and fragment-only links", () => {
diff --git a/src/shared/lib/docs/rewrite-links.ts b/src/shared/lib/docs/rewrite-links.ts
index 2ad2005..9eaa0c7 100644
--- a/src/shared/lib/docs/rewrite-links.ts
+++ b/src/shared/lib/docs/rewrite-links.ts
@@ -11,7 +11,8 @@ import { resolve } from "node:path";
*/
export const GITHUB_REPO_URL = "https://github.com/a-dev/quizbun";
-export const SITE_URL = "https://a-dev.github.io/quizbun/";
+export const SITE_URL = "https://quizbun.fyi/";
+const LEGACY_SITE_URL = "https://a-dev.github.io/quizbun/";
/** Repo Markdown files that have a site page rendered from them. */
export const DOC_SITE_PAGES: Readonly> = {
@@ -50,6 +51,10 @@ export function resolveDocLink(target: string, context: DocLinkContext): string
return joinBase(context.base, trimmedTarget.slice(SITE_URL.length));
}
+ if (trimmedTarget.startsWith(LEGACY_SITE_URL)) {
+ return joinBase(context.base, trimmedTarget.slice(LEGACY_SITE_URL.length));
+ }
+
if (/^https?:\/\//i.test(trimmedTarget)) {
return trimmedTarget;
}