Astro + GitHub Pages

Deploy a turbo-themes-powered Astro doc site to GitHub Pages with Shiki syntax highlighting, a theme switcher, and Pagefind search.

Astro + GitHub Pages Integration

This guide documents the recommended integration path used by consumer doc sites such as py-lintro and Rustume. It covers every piece of wiring that new sites need so you don’t have to rediscover it: CSS load order, Shiki css-variables binding, a persistent theme switcher, GitHub Pages subpath handling, and optional Pagefind search.

Upcoming: The Terminal flavor (#502) will ship a first-party Terminal theme and replace the native-theme workaround described in Β§5 below. Until it lands, use the pattern shown here.


1. Dependencies and Asset Copy

Install packages

bun add @lgtm-hq/turbo-themes astro @astrojs/sitemap pagefind

Copy CSS at build time

Consumer sites copy CSS from the installed package into public/ so Astro can serve them as static assets. Create scripts/copy-assets.mjs:

// scripts/copy-assets.mjs
import { cp } from 'node:fs/promises';
import { resolve } from 'node:path';

const src = resolve('node_modules/@lgtm-hq/turbo-themes/packages/css/dist');
const dest = resolve('public/assets/css');

await cp(src, dest, { recursive: true });
console.log('turbo-themes CSS copied to public/assets/css/');

Wire it into your package.json build chain:

{
  "scripts": {
    "prebuild": "node scripts/copy-assets.mjs",
    "build": "astro build"
  }
}

After the copy you will have:

public/assets/css/
β”œβ”€β”€ turbo-core.css
β”œβ”€β”€ turbo-base.css
β”œβ”€β”€ turbo-syntax.css
β”œβ”€β”€ turbo-components.css
└── themes/
    β”œβ”€β”€ catppuccin-mocha.css
    β”œβ”€β”€ catppuccin-latte.css
    β”œβ”€β”€ dracula.css
    └── …

2. BaseLayout CSS Load Order

Load the CSS bundles in this exact order inside your <head>. {base} is the GitHub Pages subpath (see Β§5):

<!-- 1. Token definitions (required) -->
<link rel="stylesheet" href="{base}/assets/css/turbo-core.css" />

<!-- 2. Base semantic styles (typography, forms, etc.) -->
<link rel="stylesheet" href="{base}/assets/css/turbo-base.css" />

<!-- 3. Shiki ↔ turbo-syntax bridge (required for code blocks) -->
<link rel="stylesheet" href="{base}/assets/css/turbo-syntax.css" />

<!-- 4. Optional component styles -->
<link rel="stylesheet" href="{base}/assets/css/turbo-components.css" />

<!-- 5. Active theme file (swapped on flavor change) -->
<link
  id="turbo-theme-css"
  rel="stylesheet"
  href="{base}/assets/css/themes/catppuccin-mocha.css"
/>

The order matters: turbo-syntax.css must come after turbo-core.css because it reads --turbo-syntax-* variables defined by the core file.


3. Astro Markdown Config and Shiki Binding

Set shikiConfig.theme to "css-variables" so Shiki emits --astro-code-* custom properties instead of hard-coded colors. turbo-syntax.css already maps those properties onto --turbo-syntax-* tokens:

--astro-code-color-text        β†’ --turbo-syntax-fg
--astro-code-color-background  β†’ --turbo-syntax-bg
--astro-code-token-keyword     β†’ --turbo-syntax-keyword
--astro-code-token-string      β†’ --turbo-syntax-string
… (full mapping in packages/css/src/syntax.ts)

In astro.config.mjs:

import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import rehypeUnwrapHeadingLinks from './src/plugins/rehypeUnwrapHeadingLinks.mjs';
import { rehypeDocLinks } from './src/plugins/rehypeDocLinks.mjs';

const base = process.env.ASTRO_BASE ?? '';

export default defineConfig({
  site: 'https://<org>.github.io',
  base, // e.g. '/my-repo' for GitHub Pages
  integrations: [sitemap()],
  markdown: {
    shikiConfig: {
      theme: 'css-variables', // required for turbo-syntax binding
      wrap: true,
    },
    rehypePlugins: [
      rehypeUnwrapHeadingLinks,
      [rehypeDocLinks, { base }], // rewrites internal links to include base
    ],
  },
});

A minimal plugin that prefixes internal hrefs with the deployment base path:

// src/plugins/rehypeDocLinks.mjs
import { visit } from 'unist-util-visit';

export function rehypeDocLinks({ base = '' } = {}) {
  return (tree) => {
    visit(tree, 'element', (node) => {
      if (node.tagName !== 'a') return;
      const href = node.properties?.href ?? '';
      if (href.startsWith('/') && !href.startsWith(base)) {
        node.properties.href = base + href;
      }
    });
  };
}

4. Theme Switcher Pattern

HTML attributes

Set data-theme and data-appearance on <html> before content renders to avoid a flash of unstyled content:

<html data-theme="catppuccin-mocha" data-appearance="dark"></html>

Inline blocking script

Place this before any stylesheets to restore the last-used theme instantly:

<script>
  (function () {
    var THEMES = [
      'catppuccin-mocha',
      'catppuccin-macchiato',
      'catppuccin-frappe',
      'catppuccin-latte',
      'dracula',
      'github-dark',
      'github-light',
      'bulma-dark',
      'bulma-light',
    ];
    var DARK = new Set([
      'catppuccin-mocha',
      'catppuccin-macchiato',
      'catppuccin-frappe',
      'dracula',
      'github-dark',
      'bulma-dark',
    ]);
    var saved = localStorage.getItem('turbo-theme');
    var theme = saved && THEMES.includes(saved) ? saved : 'catppuccin-mocha';
    var html = document.documentElement;
    html.setAttribute('data-theme', theme);
    html.setAttribute('data-appearance', DARK.has(theme) ? 'dark' : 'light');
  })();
</script>

Theme switcher function

const DARK_THEMES = new Set([
  'catppuccin-mocha',
  'catppuccin-macchiato',
  'catppuccin-frappe',
  'dracula',
  'github-dark',
  'bulma-dark',
]);

/**
 * Apply a theme and persist the choice.
 * Dispatches 'turbo-theme-applied' so other components can react.
 */
function setTheme(themeId, base = '') {
  const link = document.getElementById('turbo-theme-css');
  if (link) {
    link.href = `${base}/assets/css/themes/${themeId}.css`;
  }

  const html = document.documentElement;
  html.setAttribute('data-theme', themeId);
  html.setAttribute('data-appearance', DARK_THEMES.has(themeId) ? 'dark' : 'light');

  localStorage.setItem('turbo-theme', themeId);

  html.dispatchEvent(new CustomEvent('turbo-theme-applied', { detail: { themeId } }));
}

Consumers can listen for turbo-theme-applied to update UI state:

document.documentElement.addEventListener('turbo-theme-applied', (e) => {
  const { themeId } = e.detail;
  document.querySelectorAll('[data-theme-option]').forEach((btn) => {
    btn.classList.toggle('active', btn.dataset.themeOption === themeId);
  });
});

5. GitHub Pages: base / site Config

GitHub Pages serves repositories at https://<org>.github.io/<repo>/. Set base in astro.config.mjs via an environment variable so local dev still works at /:

# .env.production (or set in GitHub Actions)
ASTRO_BASE=/my-repo
// astro.config.mjs
const base = process.env.ASTRO_BASE ?? '';

export default defineConfig({
  site: 'https://lgtm-hq.github.io',
  base,   // Astro prefixes all asset URLs and internal links automatically
  …
});

Pass base to the CSS <link> hrefs and to the theme switcher’s setTheme(id, base) call so hotlinked CSS paths are correct on the deployed subdomain.

Astro handles <Image /> and <a href="…"> automatically when base is set. For raw <img src="…"> tags in Markdown, use the {base} variable injected by your layout component:

---
const { base } = Astro.props;
---
<img src={`${base}/images/logo.svg`} alt="Logo" />

Pagefind indexes your built site and provides a zero-JS runtime search UI.

Setup

Add to your build script:

{
  "scripts": {
    "build": "astro build && pagefind --site dist"
  }
}

Exclude chrome from search index

Add data-pagefind-ignore to navigation, sidebars, and footers so the search index contains only content:

<nav data-pagefind-ignore>…</nav>
<aside data-pagefind-ignore>…</aside>
<footer data-pagefind-ignore>…</footer>

Pagefind UI component

---
// src/components/Search.astro
---
<div id="search"></div>

<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
<script src="/pagefind/pagefind-ui.js"></script>
<script>
  window.addEventListener('DOMContentLoaded', () => {
    new PagefindUI({ element: '#search', showSubResults: true });
  });
</script>

7. CI Workflows

Site quality check

# .github/workflows/site-quality.yml
name: site-quality
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install
      - run: bun run build
        env:
          ASTRO_BASE: /my-repo

Deploy to GitHub Pages

# .github/workflows/deploy-pages.yml
name: deploy-pages
on:
  push:
    branches: [main]

permissions:
  contents: read
  pages: write
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install
      - run: bun run build
        env:
          ASTRO_BASE: /my-repo
      - uses: actions/upload-pages-artifact@v3
        with:
          path: dist/
      - id: deployment
        uses: actions/deploy-pages@v4

8. Pre-launch Checklist

  • site and base set in astro.config.mjs (use ASTRO_BASE env var)
  • CSS copied to public/assets/css/ before astro build
  • CSS load order: core β†’ base β†’ syntax β†’ components β†’ theme file
  • shikiConfig: { theme: "css-variables" } in markdown config
  • Blocking theme-restore script before stylesheets
  • #turbo-theme-css link element targeted by the switcher
  • turbo-theme-applied event dispatched on theme change
  • data-pagefind-ignore on nav / sidebar / footer
  • site-quality CI passes on every PR
  • deploy-pages workflow enabled for the main branch

Reference Implementations

These production sites use this stack and are kept up to date:

Site Theme Notes
py-lintro apps/site/ Terminal (default flavor) Resources footer pattern
Rustume apps/site/ Craft (native theme) Minimal scaffold

Next Steps