Forgejo GitHub Theme makes Forgejo 17 look like GitHub. It ships two parts, which admins install separately:
- Theme stylesheets,
dist/theme-github-*.css, one per color theme. Forgejo loads the stylesheet of the theme a user selects. Each holds the theme's color variables and the theme-wide overrides of Forgejo's own markup, so it works without the templates. - The template pack,
dist/forgejo: Forgejo template overrides and the stylesheets and scripts they load. It changes Forgejo's markup where styles alone cannot reach GitHub's layout, and it is bound to the Forgejo release it was written against (17.0.1).
bun bundle builds both. Vite generates the theme stylesheets, then scripts/assembleForgejo.ts assembles the template
pack. The local preview and the release archives use the same dist/forgejo tree.
theme.config.ts, themes/, primer/, styles/*.ts templates/, components/, styles/pages/, public/
│ │
▼ src/vite-plugin (vite build) │
dist/theme-github-*.css ────────────────────────────────────►│
▼ scripts/assembleForgejo.ts
dist/forgejo/templates, dist/forgejo/public/assets/{css,js}
│
┌──────────────────────┴──────────────────────┐
▼ ▼
local preview (dev/) release archives (.github/actions)
| Path | Holds | Ends up in | Loaded by |
|---|---|---|---|
theme.config.ts, themes/ |
Theme series and their colors | dist/theme-github-*.css |
Forgejo's theme setting |
primer/ |
Primer design tokens, generated by bun update:primer |
the theme stylesheets | themes/github.ts |
src/ |
The generator: palettes, CSS variables, Vite plugin | nothing; it runs at build time | vite.config.ts |
styles/ (*.ts) |
Theme-wide overrides of Forgejo's markup | every theme stylesheet | the theme stylesheet |
templates/ |
Overrides of Forgejo templates, at Forgejo's paths | dist/forgejo/templates |
Forgejo, in place of its own template |
components/<group>/<name>/ |
Reusable UI: template, stylesheet, script and tests | templates/components/, css/components/, js/components/ |
templates/base/component_assets.tmpl |
styles/pages/*.css |
Layout of one page or a family of pages | css/components/pages/ |
templates/base/component_assets.tmpl |
public/*.css, public/assets/js/* |
Stylesheets and scripts that a template links itself | css/, js/ |
the template that needs it |
dev/ |
Local preview: Compose file, preview tool, fixtures | nothing | bun dev:forgejo* |
css/ and js/ are under dist/forgejo/public/assets.
The theme stylesheets come from a TypeScript pipeline that turns color definitions into CSS variables and appends the style overrides:
theme.config.tsdeclares the theme series (default, colorblind, tritanopia, high contrast, pink, gitea, catppuccin) and their variants. A series with both a dark and a light variant also gets an auto theme.themes/defines the colors of each variant in one of three input formats.src/paletteconverts each format intoThemeVars, a flat map of CSS variable values.src/corewrites those values as CSS through vanilla-extract, captured bycompileCSSinsrc/vanilla-extract.ts.- The Vite plugin in
src/vite-pluginemits one file per variant: theme meta info, variables, syntax highlighting and the overrides fromstyles/, minified by LightningCSS. An auto theme only imports its dark and light files byprefers-color-scheme.
theme-github-dark.css:
├── gitea-theme-meta-info { --theme-color-scheme: dark; ... }
├── :root { --color-primary: #4493f8; ... } (200+ variables)
├── .chroma { ... }, .code-editor-container { ... } (syntax highlighting)
└── style overrides from styles/
theme-github-auto.css:
├── @import "./theme-github-dark.css?v=<hash>" (prefers-color-scheme: dark);
├── @import "./theme-github-light.css?v=<hash>" (prefers-color-scheme: light);
└── gitea-theme-meta-info { --theme-color-scheme: auto; ... }
Each source of colors has its own structure, and all three converge on ThemeVars:
Primer tokens ──→ GitHubColor ──→ ThemeColor ──→ ThemeVars
Catppuccin flavor ──────────────→ ThemeColor ──→ ThemeVars
GiteaColor ────────────────────────────────────→ ThemeVars
| Path | Use case | Complexity | Examples |
|---|---|---|---|
github |
Full Primer token compliance | High (hundreds of mapped keys) | Default, colorblind, high-contrast |
theme |
15 base colors, custom theme | Low (auto-derives 200+ CSS vars) | Catppuccin, community themes |
gitea |
Gitea native color format | Medium (direct mapping) | Gitea-compatible themes |
The theme path needs only the base colors plus primary and secondary. theme2ThemeVars() in
src/palette/theme.ts derives the rest: lightness and alpha scales, badge and message colors and
ANSI console colors. On dark themes the scales run the other way, so "dark" variants keep their contrast.
scaleColorLight() in src/functions/color.ts scales lightness proportionally, as Sass's
color.scale() does, so a change stays visible near black and white.
createGlobalThemeContract with the varMapper in src/types/vars.ts maps typed paths to the
variable names Forgejo expects: it joins the path, drops self and strips the num prefix.
| TypeScript access | CSS variable |
|---|---|
themeVars.color.primary.self |
--color-primary |
themeVars.color.primary.dark.num1 |
--color-primary-dark-1 |
themeVars.github.button.primary.bgColor.rest |
--github-button-primary-bgColor-rest |
syntaxVars.keyword |
--color-syntax-keyword |
customThemeVars.cloneMenuWidth |
--custom-clone-menu-width |
Vanilla-extract's own Vite plugin writes one CSS file per module. The theme needs one file per theme, so compileCSS
uses the low-level adapter API instead: it installs an adapter that captures appendCss and class name registrations,
runs the caller's createGlobalTheme and globalStyle calls, removes the adapter and serializes the captured objects
with transformCss. Each call is self-contained, so no state leaks from one theme into the next.
The overrides in styles/ are css template literals, composed explicitly with cssCombine() so their order is
visible:
styles/index.ts
├── public/ base elements: buttons, inputs, dropdowns, menus, tables, modals
├── components/ Forgejo pages styled by Forgejo's own markup: navbar, heatmap, issues, repository, Actions
└── templates/ markup that only the theme's older repository templates render
styles/common/ reusable fragments (cssStyle) for the files above
styles/themes/ styles for one theme series, passed as `styles` in theme.config.ts
The css tag in src/core/css.ts parses each literal with LightningCSS, so invalid CSS fails the
build with the file and line of the literal. cssStyle() turns a typed vanilla-extract style object into a reusable
declaration block, such as primaryButtonStyle in styles/common/button.ts. Every color is a theme variable.
templates/ mirrors Forgejo's templates directory. Forgejo renders a file from its custom templates directory in
place of its own file at the same path, so each override is Forgejo 17.0.1's template with the theme's changes. An
override keeps Forgejo's routes, form fields, permission checks and data, and changes the markup around them. A comment
at the top of a template says why it departs from Forgejo where that is not obvious, as in
templates/base/head_style.tmpl.
components/ holds reusable UI, grouped by what it knows about: common for controls that know no routes (dropdown,
search, sidebar, counter), and dashboard, package, project and repository for pieces of those pages. A component
directory keeps its template partials, stylesheet, browser script and tests together. Pages call a partial by its path:
{{template "components/repository/results/results" dict "Data" . "View" "overview" "Pagination" "org/repository_pagination"}}
The assembly copies each .tmpl to dist/forgejo/templates/components/<group>/<name>/, and its .css and .js to
public/assets/css/components/<group>/<name>/ and public/assets/js/components/<group>/<name>/.
templates/base/component_assets.tmpl, included at the top of base/head_navbar.tmpl, links the component stylesheets
and scripts and the page stylesheets, so they load on every page with the navbar.
CONTRIBUTING.md describes the shared controls and where new styles go.
styles/pages/*.css is plain CSS for the layout of one page or a family of pages, scoped by a page prefix such as
profile- or org-. The assembly copies every file in styles/pages to public/assets/css/components/pages/; a new
one only needs its <link> in templates/base/component_assets.tmpl, which a test checks. public/ holds stylesheets
and scripts that a template links itself, such as the Actions run view's; they load only on the pages that render that
template.
Scripts are plain JavaScript without a bundler, most loaded with defer. Each wraps itself in a function, looks up the
markup it enhances, often by a data-theme-* attribute, and returns when the page does not have it. They enhance
server-rendered markup that works without them: components/repository/results/results.js replaces the result list with
the same GET route's response, and the form still submits without it. Script tests load the script into a node:vm
context with a stubbed document.
Forgejo serves custom assets with a long cache lifetime and versions the theme stylesheet link with its own release
only. Templates therefore link assets without a version, and the assembly adds one: every {{AssetUrlPrefix}} link gets
the content hash of the file it loads, the theme link in base/head_style.tmpl gets the hash of all theme files, and
each auto theme's @import gets the hash of the imported file.
The footer in base/footer_content.tmpl links the theme's repository with its version. The template declares
{{$themeVersion := "dev"}} and the assembly replaces the value: with THEME_VERSION, which the release build sets to
the release tag, or else with git describe --tags, such as 1.4.0-3-g54b4d73 for a build three commits after 1.4.0.
Admins hide the credit with --custom-footer-credit: none.
dev/docker-compose.yml runs the Forgejo release pinned in package.json (17.0.1-rootless) and mounts
dev/.generated/public/assets/css, dev/.generated/public/assets/js and dev/.generated/templates read-only into its
custom directory. These are a copy of dist/forgejo with the preview's automatic sign-in and page reload added, so the
hooks never reach a release and dist stays as bun bundle wrote it. The preview tool in dev/preview (started by
dev/dev.ts) builds, starts Forgejo, seeds it from dev/fixtures, and rebuilds and restarts on source changes.
README.md covers its use and dev/fixtures/README.md the
fixture format.
.github/actions/build-release-assets/package.sh packs the theme stylesheets into three archives (base, accessibility,
extras) and the template pack into theme-github-templates.tar.gz: the templates, all scripts and every stylesheet
except the theme stylesheets, laid out to extract into Forgejo's custom directory.
Forgejo links one stylesheet for the selected theme, so the overrides in styles/ are copied into each of the 23
non-auto theme files. Component and page stylesheets ship once, beside the templates, but only installs with the
template pack load them. A rule for Forgejo's own markup therefore belongs in styles/, where installs with only the
stylesheets get it too, and a rule for markup that only the theme's templates render belongs beside that template.
The theme uses vanilla-extract's createGlobalThemeContract and createGlobalTheme for typed CSS variables and
serializes them at build time through the adapter API. The adapter API is undocumented and may change between versions;
the locked @vanilla-extract/css version and the palette snapshot tests catch changes in the output.
LightningCSS checks every css literal and minifies the output. It recovers from many syntax errors instead of failing,
so validation catches severe errors only; the build output tests cover part of the rest.
Development and builds require Bun 1.3.14 or later, which runs TypeScript natively and gives the stack traces that
getCallerInfo parses.
Modules import each other through the package's own exports, such as @lutinglt/gitea-github-theme/core, with paths
in tsconfig.json for editors. Import paths stay the same whether the code runs as a dependency or in this repository.
Define the colors in themes/ with defineTheme, register the series in theme.config.ts and run bun bundle; the
stylesheets appear in dist/. CONTRIBUTING.md has a complete example. For
exact GitHub colors, use the github path with Primer tokens, as themes/github.ts and src/palette/github.ts do.
bunx vitest run runs every test; bun bundle must run first for the build output tests.
| Area | Location | What it verifies |
|---|---|---|
| Generator | src/core, src/functions, src/utils |
css() validation and composition, color scaling, variable name extraction, deepOverride |
| Palette | src/palette/*.test.ts |
Snapshots of the color conversion chains and valid color values |
| Build output | src/build-output.test.ts |
Theme files and their variables, Forgejo-specific rules, every shipped stylesheet unchanged and linked, every asset link versioned |
| Page scripts | src/navbar-drawer.test.ts, src/repo-mirror.test.ts, src/actions-run-graph.test.ts |
Scripts in public/assets/js |
| Components | components/**/*.test.ts |
Each component's template contract, styles and script behavior |
| Preview | dev/preview/**/*.test.ts, src/preview-* |
Fixture checks without Docker and the preview's reload hooks |
- CONTRIBUTING.md: development setup, conventions and pull requests
- README.md: installation and the local preview
- dev/fixtures/README.md: the preview's fixtures
- primer/README.md: the generated Primer tokens