diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 182bf7f7..a9be0047 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -281,6 +281,8 @@ jobs: lockfiles: | website/pnpm-lock.yaml - name: Build and test the website + env: + WEBSITE_URL: https://minimax-ai.github.io/OpenAgentCore/ run: make check-website web-acceptance: diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 0cd20251..dbe8cd3e 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -36,7 +36,7 @@ jobs: timeout-minutes: 10 permissions: contents: read - # configure-pages reads the site's base path. + # The build reads the site's URL from the Pages API. pages: read env: PUBLISH: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} @@ -48,14 +48,18 @@ jobs: - uses: ./.github/actions/node with: lockfiles: website/pnpm-lock.yaml - - name: Read the Pages base path - id: pages - if: env.PUBLISH == 'true' - uses: actions/configure-pages@v6 - name: Build and test - run: make check-website env: - WEBSITE_BASE: ${{ steps.pages.outputs.base_path }} + GH_TOKEN: ${{ github.token }} + # Exercise the published project path on pull requests. + WEBSITE_URL: https://minimax-ai.github.io/OpenAgentCore/ + run: | + if [[ "$PUBLISH" == "true" ]]; then + WEBSITE_URL="$(gh api "repos/$GITHUB_REPOSITORY/pages" --jq .html_url)" + : "${WEBSITE_URL:?The Pages API returned an empty site URL}" + export WEBSITE_URL + fi + make check-website - name: Upload the site if: env.PUBLISH == 'true' uses: actions/upload-pages-artifact@v5 diff --git a/website/.vitepress/config.mts b/website/.vitepress/config.mts index 9d9edf6c..27915e7f 100644 --- a/website/.vitepress/config.mts +++ b/website/.vitepress/config.mts @@ -4,6 +4,7 @@ import { dirname, resolve } from 'node:path' import { defineConfig, type SiteConfig } from 'vitepress' import { mermaidFences } from './mermaid.mts' import { repositoryLinks } from './repository-links.mts' +import { siteBase } from './site-base.mts' import { docsSidebar, legacyRedirects, pageTitle, readDocsJson, repoRoot } from './docs-nav.mts' // Pages outside this package resolve Vue from here, not from the repository root. @@ -13,14 +14,7 @@ const vueDir = dirname(require.resolve('vue/package.json')) const repo = 'https://github.com/MiniMax-AI/OpenAgentCore' const description = 'An open-source, self-hosted implementation of the OpenAI Agents API with multiple native harnesses.' -// GitHub Pages passes its base path (for example `/OpenAgentCore/`, or `/` on a -// custom domain) to the build; local builds serve from the root. -const base = normalizeBase(process.env.WEBSITE_BASE) - -function normalizeBase(value: string | undefined): string { - const trimmed = (value ?? '').replace(/^\/+|\/+$/g, '') - return trimmed ? `/${trimmed}/` : '/' -} +const base = siteBase(process.env.WEBSITE_URL) // The canonical mark, inlined so the favicon has no second copy of the logo. const logoSvg = readFileSync(resolve(repoRoot, 'docs/assets/openagentcore-logo.svg'), 'utf8') diff --git a/website/.vitepress/site-base.mts b/website/.vitepress/site-base.mts new file mode 100644 index 00000000..5f379576 --- /dev/null +++ b/website/.vitepress/site-base.mts @@ -0,0 +1,15 @@ +/** Local builds use the root; published builds derive their path from the Pages URL. */ +export function siteBase(value: string | undefined): string { + if (value === undefined) return '/' + + let url: URL + try { + url = new URL(value) + } catch { + throw new Error('WEBSITE_URL must be an absolute HTTP(S) URL') + } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + throw new Error('WEBSITE_URL must be an absolute HTTP(S) URL without credentials, query or fragment') + } + return url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/` +} diff --git a/website/README.md b/website/README.md index 3aed20ce..66f1cc8f 100644 --- a/website/README.md +++ b/website/README.md @@ -23,4 +23,8 @@ The landing page lives in `.vitepress/theme/`. `landing-content.ts` holds its En ## Publish -`make check-website` builds and tests the site. core-check runs it for changes under `website/` and to Node dependencies; `.github/workflows/website.yml` runs it for documentation-only pull requests and deploys `main` to GitHub Pages. A repository administrator enables Pages once: **Settings → Pages → Source: GitHub Actions**. The build reads the Pages base path, so the site works both at `https://.github.io//` and on a custom domain set under **Settings → Pages → Custom domain**. +`make check-website` builds and tests the site. core-check runs it for changes under `website/` and to Node dependencies; `.github/workflows/website.yml` runs it for documentation-only pull requests and deploys `main` to GitHub Pages. A repository administrator enables Pages once: **Settings → Pages → Source: GitHub Actions**. + +The publishing step reads `html_url` directly from the GitHub Pages API and passes it to the build as `WEBSITE_URL`. VitePress derives the base path from that URL, supporting both `https://.github.io//` and a custom domain set under **Settings → Pages → Custom domain**. An empty or invalid URL stops the build. Local builds omit `WEBSITE_URL` to serve from `/`. + +Pull request checks build under the published `/OpenAgentCore/` path. Output tests verify that generated navigation, assets, redirects and `llms.txt` links use the configured path and that local HTML links resolve to generated files. To check a deployment path locally, run `WEBSITE_URL=https://example.com/OpenAgentCore/ make check-website`. diff --git a/website/tests/dist.test.mjs b/website/tests/dist.test.mjs index 03614687..88ea486b 100644 --- a/website/tests/dist.test.mjs +++ b/website/tests/dist.test.mjs @@ -2,7 +2,7 @@ // an HTML page and a raw Markdown copy, legacy paths redirect, both landing // pages exist and llms.txt lists every page. import assert from 'node:assert/strict' -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { resolve } from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' @@ -10,6 +10,8 @@ import { legacyRedirects, readDocsJson } from '../.vitepress/docs-nav.mts' const dist = fileURLToPath(new URL('../.vitepress/dist/', import.meta.url)) const pages = readDocsJson().navigation.groups.flatMap((g) => g.pages) +const basePath = new URL(process.env.WEBSITE_URL ?? 'http://localhost/').pathname +const base = basePath.endsWith('/') ? basePath : `${basePath}/` function html(page) { return resolve(dist, `${page}.html`) @@ -34,16 +36,32 @@ test('every documentation page has HTML and a Markdown copy', () => { } }) +test('generated navigation and assets stay under the site base and resolve to files', () => { + let checked = 0 + for (const file of readdirSync(dist, { recursive: true }).filter((file) => file.endsWith('.html'))) { + const source = readFileSync(resolve(dist, file), 'utf8') + for (const [, href] of source.matchAll(/\b(?:href|src)="(\/[^"\s]*)"/g)) { + assert.ok(href.startsWith(base) && !href.startsWith('//'), `${file}: ${href} must start with ${base}`) + const path = decodeURI(href.split(/[?#]/)[0].slice(base.length)) + const candidates = [resolve(dist, path), resolve(dist, `${path}.html`), resolve(dist, path, 'index.html')] + assert.ok(candidates.some((candidate) => existsSync(candidate) && statSync(candidate).isFile()), `${file}: ${href} must resolve to a generated file`) + checked++ + } + } + assert.ok(checked > 0, 'generated pages must contain local navigation and assets') +}) + test('legacy paths redirect', () => { for (const { from, to } of legacyRedirects()) { const source = readFileSync(resolve(dist, `${from}.html`), 'utf8') - assert.ok(source.includes(`url=`) && source.includes(to.replace(/^\//, '')), `${from} → ${to}`) + const target = `${base}${to.replace(/^\//, '')}` + assert.ok(source.includes(`content="0; url=${target}"`), `${from} → ${target}`) } }) test('llms.txt lists every page', () => { const llms = readFileSync(resolve(dist, 'llms.txt'), 'utf8') - for (const page of pages) assert.ok(llms.includes(`${page}.md)`), `${page} in llms.txt`) + for (const page of pages) assert.ok(llms.includes(`](${base}${page}.md)`), `${page} in llms.txt uses ${base}`) }) test('Mermaid fences become diagrams, not code blocks', () => { diff --git a/website/tests/site-base.test.mjs b/website/tests/site-base.test.mjs new file mode 100644 index 00000000..a663506d --- /dev/null +++ b/website/tests/site-base.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { siteBase } from '../.vitepress/site-base.mts' + +test('local builds serve from the root', () => { + assert.equal(siteBase(undefined), '/') +}) + +test('Pages URLs support project paths, organization sites and custom domains', () => { + for (const [url, expected] of [ + ['http://minimax-ai.github.io/OpenAgentCore/', '/OpenAgentCore/'], + ['https://octocat.github.io/my-repo', '/my-repo/'], + ['https://octocat.github.io/', '/'], + ['https://docs.example.com', '/'], + ['https://docs.example.com/nested/site/', '/nested/site/'], + ]) assert.equal(siteBase(url), expected, url) +}) + +test('empty or invalid Pages metadata fails instead of building at the root', () => { + for (const value of ['', ' ', 'null', '/OpenAgentCore/', 'file:///tmp/site', 'https://user:pass@example.com/', 'https://example.com/?path=docs', 'https://example.com/#docs']) { + assert.throws(() => siteBase(value), /WEBSITE_URL/, JSON.stringify(value)) + } +})