diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 85422c97..5eda0ddf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,8 +36,16 @@ jobs: - name: 'Install dependencies' run: bun install --frozen-lockfile + - name: 'Check content (MDX that could run code)' + run: bun run check:content + + - name: 'Test the content check' + run: bun run test:content + - name: 'Typecheck' run: bun run typecheck + # prebuild runs check:content again (Netlify builds run it there); + # postbuild runs check:paths on out/. - name: 'Build' run: bun run build diff --git a/.github/workflows/preview-build.yml b/.github/workflows/preview-build.yml new file mode 100644 index 00000000..344aaecb --- /dev/null +++ b/.github/workflows/preview-build.yml @@ -0,0 +1,81 @@ +# Publishes this branch's built /docs as static files for the Hub's preview deployment +# (hub-preview.pelican.dev), so the preview shows the whole one-site pelican.dev: the Hub's +# pages and these docs under /docs. +# +# The branch preview/docs-under-docs holds exactly what goes in the Hub's public/docs/: +# out/docs/*, plus out/_next/ as _next/ (pages load /docs/_next/... because of assetPrefix, +# while the export writes those files to out/_next/), plus BUILD.json naming the commit. +# The Hub installs it on the preview host (App\Services\Site\DocsPreviewService); the last +# step asks the preview to check for the new build right away. +name: 'Preview build' +on: + workflow_dispatch: + push: + branches: + - 'feat/docs-under-docs' + +concurrency: + group: preview-build + cancel-in-progress: true + +permissions: + contents: write + +jobs: + publish: + name: 'Build and publish /docs for the Hub preview' + runs-on: ubuntu-latest + steps: + - name: 'Checkout' + uses: actions/checkout@v7 + + - name: 'Setup Bun' + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: 'Setup Node' + uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: 'Cache Bun packages' + uses: actions/cache@v6 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: bun-${{ runner.os }}- + + - name: 'Install dependencies' + run: bun install --frozen-lockfile + + # prebuild checks the content, postbuild checks every file lands where it should. + - name: 'Build' + run: bun run build + + - name: 'Assemble the /docs folder' + run: | + rm -rf preview + mkdir preview + cp -R out/docs/. preview/ + cp -R out/_next preview/_next + test -f preview/index.html + test -d preview/_next/static + printf '{"sha":"%s","branch":"%s","built_at":"%s"}\n' \ + "$GITHUB_SHA" "$GITHUB_REF_NAME" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > preview/BUILD.json + + - name: 'Publish to preview/docs-under-docs' + working-directory: preview + env: + GH_TOKEN: ${{ github.token }} + run: | + git init -q -b preview + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -q -m "Docs preview build of ${GITHUB_SHA::10} (${GITHUB_REF_NAME})" + git push -q -f "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:refs/heads/preview/docs-under-docs + + # Not fatal: the preview also checks on its own every 10 minutes and on the first visit. + - name: 'Ask the Hub preview to install it' + run: curl -fsS --max-time 60 https://hub-preview.pelican.dev/docs/_refresh || echo "The preview will pick it up on its next check." diff --git a/README.md b/README.md index 3a03b8e3..4f8df9b0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Pelican Docs -The website and documentation at [pelican.dev](https://pelican.dev), covering [Pelican Panel](https://github.com/pelican/panel) and [Wings](https://github.com/pelican/wings). +The source for the documentation at [pelican.dev/docs](https://pelican.dev/docs), covering [Pelican Panel](https://github.com/pelican/panel) and [Wings](https://github.com/pelican/wings). + Built with [Next.js](https://nextjs.org) and [Fumadocs](https://fumadocs.dev) and served by Netlify as a static site. ## Running it locally @@ -23,6 +24,9 @@ bun run build # the production site, written to out/ | `app/(home)/page.tsx` | The homepage. | | `public/` | Files served as they are, including the `update-panel.sh` and `migrate-*.sh` scripts. | | `netlify.toml` | Build settings and redirects such as `/discord`. | +| `ui/` | Design tokens (`tokens.css`) and site nav (`nav.json`) shared with the Hub; published at `/docs/ui/v1/`. | +| `router/` | `dev-server.mjs`: pelican.dev as one site locally (docs from `out/`, the rest from the Hub). See its README. | +| `scripts/check-*.mjs` | `check-content` (parses every page as MDX and fails on anything that could run code: `import`/`export`, `{expressions}`, components outside `mdx-components.tsx`, handlers, unsafe URLs; runs before the build, tested by `bun run test:content`) and `check-paths` (everything under `/docs/`, runs after it). | ## Writing a page diff --git a/app/(landing)/home.css b/app/(landing)/home.css index a9661aef..4c5d3db7 100644 --- a/app/(landing)/home.css +++ b/app/(landing)/home.css @@ -8,37 +8,8 @@ * ====================================================================== */ .pl { - --ink: #04121c; - --sky: #38bdf8; - --sky-deep: #0b79ad; - --white: #ffffff; - --paper: #f3f7f9; - --text: #1f3644; - --on-ink-soft: #b9ccd6; - --line-on-ink: #2e4756; - --line: 3px solid var(--ink); - --pad: clamp(16px, 4cqi, 64px); - --section: clamp(72px, 9cqi, 120px); - --font-display: var(--pl-font-display), 'Helvetica Neue', sans-serif; - --font-text: var(--pl-font-text), 'Helvetica Neue', system-ui, sans-serif; - --font-code: var(--font-jetbrains), ui-monospace, Menlo, monospace; - --btn-shadow: var(--ink); - /* Theme roles. --ink and --white flip in dark mode (see "Dark theme" at the - end); these stay put or carry the role the light values had. */ - --navy: #04121c; /* always navy: text on sky, chrome that is dark in both themes */ - --snow: #ffffff; /* always white: text on navy */ - --shadow: var(--ink); /* hard offset shadow on cards */ - --muted: #33505f; - --faint: #8aa3b0; - --rule: #d5e3ea; - --rule-soft: #e3edf2; - --rule-ink: rgba(4, 18, 28, 0.12); - --chip: #e3edf2; - --hover: #e8f6fd; - --hover-strong: #cdeefd; - --on-sky-soft: #0b2a3b; - --kbd-bg: rgba(255, 255, 255, 0.5); - + /* The colours, fonts and sizes (--ink, --sky, --line, --pad, ...) are in + ui/tokens.css, loaded on every page by app/global.css. */ container: page / inline-size; flex: 1; display: flex; @@ -4556,27 +4527,7 @@ html:has(.pl-ed[open]) { * keep their light values and only the bands around them change. * ====================================================================== */ .dark .pl { - --ground: #04121c; - --deep: #071926; - --code: #000b12; - --ink: #ffffff; - --white: #0a1e2b; - --card: #0a1e2b; /* the surface, also inside .on-ink where --white is white again */ - --paper: #071926; - --text: #d4e3ea; - --muted: #b9ccd6; - --faint: #7f99a6; - --rule: #2e4756; - --rule-soft: #1f3a4a; - --rule-ink: rgba(185, 204, 214, 0.18); - --chip: #14304a; - --hover: #0f2c3d; - --hover-strong: #123a52; - --on-sky-soft: #b9ccd6; - --kbd-bg: rgba(255, 255, 255, 0.1); - --sky-deep: #38bdf8; - --shadow: var(--sky); - --btn-shadow: var(--sky); + /* The dark tokens are in ui/tokens.css. */ background: var(--ground); } @@ -4585,13 +4536,7 @@ html:has(.pl-ed[open]) { color: var(--navy); } -/* Navy sections were already dark: give them back their light values. */ -.dark .pl .on-ink { - --ink: #04121c; - --white: #ffffff; - --line: 3px solid var(--line-on-ink); - --btn-shadow: var(--snow); -} +/* Navy (.on-ink) sections get their light values back in ui/tokens.css. */ /* ---- Full-width bands: navy grounds with slate edges, no bright blocks ---- */ .dark .pl :is(.pl-features, .pl-activity, .pl-sup-faq) { diff --git a/app/md/[...slug]/route.ts b/app/docs/md/[...slug]/route.ts similarity index 84% rename from app/md/[...slug]/route.ts rename to app/docs/md/[...slug]/route.ts index 9aaf3c3b..1899cff5 100644 --- a/app/md/[...slug]/route.ts +++ b/app/docs/md/[...slug]/route.ts @@ -1,7 +1,7 @@ import { notFound } from 'next/navigation'; import { markdownParams, pageFromMarkdownSlug, pageMarkdown } from '@/lib/llms'; -// One docs page as Markdown. Builds to out/md/.md; netlify.toml serves +// One docs page as Markdown. Builds to out/docs/md/.md; netlify.toml serves // it at /docs/.md (the docs page route owns everything under /docs). export const dynamic = 'force-static'; export const dynamicParams = false; @@ -10,7 +10,7 @@ export function generateStaticParams() { return markdownParams(); } -export async function GET(_request: Request, { params }: RouteContext<'/md/[...slug]'>) { +export async function GET(_request: Request, { params }: RouteContext<'/docs/md/[...slug]'>) { const { slug } = await params; const page = pageFromMarkdownSlug(slug); if (!page) notFound(); diff --git a/app/og/docs/[...slug]/route.tsx b/app/docs/og/[...slug]/route.tsx similarity index 96% rename from app/og/docs/[...slug]/route.tsx rename to app/docs/og/[...slug]/route.tsx index ee7a51aa..14165e55 100644 --- a/app/og/docs/[...slug]/route.tsx +++ b/app/docs/og/[...slug]/route.tsx @@ -7,7 +7,7 @@ import { checkedMotif } from '@/lib/og-motifs'; import { source } from '@/lib/source'; // Share images for the docs, written into out/ at build time: -// /og/docs/image.png for /docs/ and /og/docs//image.png for every other page. +// /docs/og/image.png for /docs/ and /docs/og//image.png for every other page. // The last segment is the file name, so a catch-all can stay the last part of the route. export const dynamic = 'force-static'; export const dynamicParams = false; @@ -32,7 +32,7 @@ function placeOf(url: string): { section: DocsSection; trail: string[] } { return section ? { section, trail } : { section: 'Project', trail: [] }; } -export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) { +export async function GET(_req: Request, { params }: RouteContext<'/docs/og/[...slug]'>) { const { slug } = await params; if (slug.at(-1) !== FILE) notFound(); const page = source.getPage(slug.slice(0, -1)); diff --git a/app/api/search/route.ts b/app/docs/search.json/route.ts similarity index 57% rename from app/api/search/route.ts rename to app/docs/search.json/route.ts index c1d933fb..b89a81e3 100644 --- a/app/api/search/route.ts +++ b/app/docs/search.json/route.ts @@ -1,7 +1,7 @@ import { createFromSource } from 'fumadocs-core/search/server'; import { source } from '@/lib/source'; -// Built once at build time into a static file; the search box downloads it -// and searches in the browser, so the site needs no server. +// Built once at build time into out/docs/search.json; the search box (app/layout.tsx) +// downloads it and searches in the browser, so the site needs no server. export const revalidate = false; export const { staticGET: GET } = createFromSource(source, { language: 'english' }); diff --git a/app/docs/sitemap.ts b/app/docs/sitemap.ts new file mode 100644 index 00000000..f90d8fff --- /dev/null +++ b/app/docs/sitemap.ts @@ -0,0 +1,17 @@ +import type { MetadataRoute } from 'next'; +import { absoluteUrl } from '@/lib/og'; +import { source } from '@/lib/source'; + +// Written once into out/docs/sitemap.xml by the static export. The Hub's +// /sitemap.xml index links here. +export const dynamic = 'force-static'; + +/** Every docs page, with its git last-modified date. */ +export default function sitemap(): MetadataRoute.Sitemap { + return source.getPages().map((page) => ({ + url: absoluteUrl(page.url), + lastModified: page.data.lastModified, + changeFrequency: 'weekly', + priority: page.slugs.length === 0 ? 0.9 : 0.7, + })); +} diff --git a/app/docs/ui/v1/nav.json/route.ts b/app/docs/ui/v1/nav.json/route.ts new file mode 100644 index 00000000..182bd93c --- /dev/null +++ b/app/docs/ui/v1/nav.json/route.ts @@ -0,0 +1,11 @@ +import { siteNav } from '@/lib/nav'; + +// /docs/ui/v1/nav.json: ui/nav.json (the site header, footer and call to action), +// for the Hub to copy. v1 is the shape of the file. +export const dynamic = 'force-static'; + +export function GET() { + return new Response(`${JSON.stringify(siteNav, null, 2)}\n`, { + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }); +} diff --git a/app/docs/ui/v1/tokens.css/route.ts b/app/docs/ui/v1/tokens.css/route.ts new file mode 100644 index 00000000..4927bb80 --- /dev/null +++ b/app/docs/ui/v1/tokens.css/route.ts @@ -0,0 +1,11 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +// /docs/ui/v1/tokens.css: ui/tokens.css as it is, for the Hub to copy. +// v1 is the shape of the file; bump the folder when a change would break a copy. +export const dynamic = 'force-static'; + +export async function GET() { + const css = await readFile(join(process.cwd(), 'ui/tokens.css'), 'utf8'); + return new Response(css, { headers: { 'Content-Type': 'text/css; charset=utf-8' } }); +} diff --git a/app/global.css b/app/global.css index 963f2753..14a377f7 100644 --- a/app/global.css +++ b/app/global.css @@ -1,6 +1,8 @@ @import 'tailwindcss'; @import 'fumadocs-ui/css/neutral.css'; @import 'fumadocs-ui/css/preset.css'; +/* The V12 tokens shared with the Hub (published at /docs/ui/v1/tokens.css). */ +@import '../ui/tokens.css'; /* ========================================================================= * Site-wide theme, in the V12 "B v3" style: navy #04121C, sky #38BDF8, @@ -16,16 +18,8 @@ --font-mono: var(--font-jetbrains), ui-monospace, SFMono-Regular, Menlo, monospace; } +/* Fumadocs' colours; the --pl-* brand colours come from ui/tokens.css. */ :root { - --pl-ink: #04121c; - --pl-sky: #38bdf8; - --pl-sky-deep: #0b79ad; - --pl-white: #ffffff; - --pl-paper: #f3f7f9; - /* the thick edge and hard shadow used on cards, code and callouts */ - --pl-edge: var(--pl-ink); - --pl-shadow: var(--pl-ink); - --color-fd-background: #f3f7f9; --color-fd-foreground: #04121c; --color-fd-muted: #e3edf2; @@ -51,9 +45,6 @@ } .dark { - --pl-edge: #2e4756; - --pl-shadow: #38bdf8; - --color-fd-background: #04121c; --color-fd-foreground: #f3f7f9; --color-fd-muted: #0a1f2e; diff --git a/app/layout.tsx b/app/layout.tsx index 8ff22cbd..7fc358e0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -24,7 +24,7 @@ export const metadata: Metadata = { description: 'Pelican is a free, open-source panel for running game servers and applications in isolated Docker containers.', applicationName: SITE_NAME, - icons: { icon: '/img/favicon.ico', apple: '/img/logo.png' }, + icons: { icon: '/docs/img/favicon.ico', apple: '/docs/img/logo.png' }, // Fallbacks only: each page sets its own canonical, Open Graph and Twitter card // through pageMetadata() in lib/og.ts. openGraph: { @@ -41,10 +41,11 @@ export default function RootLayout({ children }: LayoutProps<'/'>) { {children} diff --git a/app/robots.ts b/app/robots.ts index 88e272d9..56de5be1 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -2,6 +2,7 @@ import type { MetadataRoute } from 'next'; import { IS_PRODUCTION, SITE_URL } from '@/lib/og'; // Written once into out/robots.txt by the static export. +// INTERIM: after the switch the Hub serves /robots.txt (and should list /docs/sitemap.xml). export const dynamic = 'force-static'; /** Everything is open, AI crawlers included: the owner wants the docs discoverable. */ @@ -9,6 +10,6 @@ export default function robots(): MetadataRoute.Robots { return { // Deploy previews are closed to crawlers; production is open to everyone. rules: IS_PRODUCTION ? { userAgent: '*', allow: '/' } : { userAgent: '*', disallow: '/' }, - sitemap: `${SITE_URL}/sitemap.xml`, + sitemap: [`${SITE_URL}/sitemap.xml`, `${SITE_URL}/docs/sitemap.xml`], }; } diff --git a/app/sitemap.ts b/app/sitemap.ts index b66ef176..addda019 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,13 +1,16 @@ import type { MetadataRoute } from 'next'; import { absoluteUrl } from '@/lib/og'; -import { pagesSource, source } from '@/lib/source'; +import { pagesSource } from '@/lib/source'; // Written once into out/sitemap.xml by the static export. +// INTERIM: the landing pages only (remove after the switch, with them). The docs +// have their own /docs/sitemap.xml (app/docs/sitemap.ts); robots.txt lists both, +// and the Hub will serve /sitemap.xml as an index that points at the docs one. export const dynamic = 'force-static'; -/** Every landing page and every docs page, with the docs' git last-modified dates. */ +/** The landing pages (/, /eggs, /support, /faq). */ export default function sitemap(): MetadataRoute.Sitemap { - const landing: MetadataRoute.Sitemap = [ + return [ { url: absoluteUrl('/'), changeFrequency: 'weekly', priority: 1 }, { url: absoluteUrl('/eggs/'), changeFrequency: 'daily', priority: 0.8 }, { url: absoluteUrl('/support/'), changeFrequency: 'monthly', priority: 0.6 }, @@ -18,13 +21,4 @@ export default function sitemap(): MetadataRoute.Sitemap { priority: 0.6, })), ]; - - const docs: MetadataRoute.Sitemap = source.getPages().map((page) => ({ - url: absoluteUrl(page.url), - lastModified: page.data.lastModified, - changeFrequency: 'weekly', - priority: page.slugs.length === 0 ? 0.9 : 0.7, - })); - - return [...landing, ...docs]; } diff --git a/bun.lock b/bun.lock index 20a62523..db96648c 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,9 @@ "@types/node": "26.6.6", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", + "mdast-util-from-markdown": "2.1.0", + "mdast-util-mdx": "3.0.0", + "micromark-extension-mdxjs": "3.0.0", "postcss": "8.5.28", "sharp": "0.35.5", "tailwindcss": "4.3.3", @@ -32,6 +35,11 @@ }, }, }, + "trustedDependencies": [ + "@tailwindcss/oxide", + "sharp", + "esbuild", + ], "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.3.0", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="], diff --git a/components/docs/docs-top-bar.tsx b/components/docs/docs-top-bar.tsx index b8de1759..79c16064 100644 --- a/components/docs/docs-top-bar.tsx +++ b/components/docs/docs-top-bar.tsx @@ -4,6 +4,7 @@ import { FullSearchTrigger } from 'fumadocs-ui/layouts/shared/slots/search-trigg import { ThemeSwitch } from 'fumadocs-ui/layouts/shared/slots/theme-switch'; import { links } from '@/lib/links'; import { project } from '@/lib/home-data'; +import { navOutsideDocs, siteNav } from '@/lib/nav'; /** * The B v3 top bar for the docs (desktop and tablet). On phones Fumadocs' @@ -12,26 +13,28 @@ import { project } from '@/lib/home-data'; export function DocsTopBar() { return (
- + {/* Plain links: / and the paths in the site nav are the Hub's pages, not this app's. */} + {/* eslint-disable-next-line @next/next/no-img-element */} - + Pelican Docs - +
{(project.stars / 1000).toFixed(1)}k - - Get started + + {siteNav.cta.label}
diff --git a/components/home/feature-panel.tsx b/components/home/feature-panel.tsx index ee96f2d2..6d20e1d9 100644 --- a/components/home/feature-panel.tsx +++ b/components/home/feature-panel.tsx @@ -63,7 +63,7 @@ export function FeaturePanel() {
{/* eslint-disable-next-line @next/next/no-img-element */} - + Pelican / Servers / Minecraft SMP diff --git a/components/home/sections.tsx b/components/home/sections.tsx index 60dccacd..922b8bcc 100644 --- a/components/home/sections.tsx +++ b/components/home/sections.tsx @@ -195,7 +195,7 @@ export function FeaturesSection() {
{/* eslint-disable-next-line @next/next/no-img-element */} - + Panel Web UI + API @@ -480,7 +480,7 @@ export function SiteFooterNav() {
{/* eslint-disable-next-line @next/next/no-img-element */} - + Pelican

A free, open-source game server panel, built by its community.

diff --git a/components/home/site-header.tsx b/components/home/site-header.tsx index 163bd068..99516c0c 100644 --- a/components/home/site-header.tsx +++ b/components/home/site-header.tsx @@ -22,7 +22,7 @@ export function SiteHeader() {
{/* eslint-disable-next-line @next/next/no-img-element */} - + Pelican