Rebuild the site with Astro and Starlight (#496)

Replace [#307](https://gitea.com/gitea/docs/issues/307)
Closes [#237](https://gitea.com/gitea/docs/issues/237)

## Summary

Rebuilds docs.gitea.com with [Astro](https://astro.build) and [Starlight](https://starlight.astro.build), replacing Docusaurus. Every
published url keeps working and no content file was moved: the site is a new rendering layer over the existing `docs/`, `versioned_docs/`, `i18n/`, `runner-docs/` and `static/swagger-*.json` trees.

For the first time the API reference is part of the site rather than a Redoc
bundle: all seven swagger documents are rendered into real pages, one per
operation, so they are linkable, crawlable and searchable.

## Why

- the Docusaurus build needs an 8 GB heap and about 2 minutes for 2478 pages;
  the Astro build produces 5751 pages, API reference included, in about 100
  seconds
- the API reference was a single client rendered Redoc page per version, absent
  from the site search and from search engines
- sidebars, version lists and language lists were configured in three different
  places and drifted apart

## What is in here

- **`packages/content-loader`** — the product × version × language matrix
  (`products.ts`) and an Astro content loader that reads the existing markdown
  trees directly. The matrix is the single source of truth: content loading,
  sidebars, the version and language pickers, the version banner, the search
  facets and the API schemas are all derived from it.
- **Content compatibility** — frontmatter `slug`, `sidebar_position` and
  `sidebar_label` keep working, `_category_.json` still drives sidebar labels,
  order and the generated category pages, `@version@` style release variables are
  still substituted, `:::note` admonitions become Starlight asides and the 2778
  relative `*.md` links are rewritten to urls while the site is built.
- **API reference** — `starlight-openapi` renders the seven swagger documents.
  Operation urls are hyphenated (`/api/operations/list-admin-workflow-jobs/`),
  the sidebar shows the operations of the version being read with a coloured
  HTTP method badge, and the overview page links to one page per tag instead of
  repeating all 484 operations.
- **Navigation** — a version picker that follows the reader to the same page in
  another version, a language picker that only offers the languages the current
  product is published in, and product links for Docs, API, Runner and
  Enterprise.
- **Theme** — the light and dark palettes of about.gitea.com, mapped onto the
  Starlight variables.
- **Search** — Pagefind by default; Algolia DocSearch takes over when
  `PUBLIC_DOCSEARCH_APP_ID` and `PUBLIC_DOCSEARCH_API_KEY` are set. Every page
  carries `docsearch:product`, `docsearch:version` and `docsearch:language` meta
  tags, so a search stays inside what is being read.
  `cloudflare/docsearch-crawler.json` holds the crawler configuration.

## Bugs fixed along the way

- `GET /user/applications/oauth2` and `GET /user/applications/oauth2/{id}` have
  operation ids that only differ in case, so they collapsed onto the same url
  and one of the two pages was silently dropped. They are now
  `user-get-oauth2-application` and `user-get-oauth2-application-by-id`.
- The API overview and every tag page were all titled "Overview".

## Url compatibility

The routes of both builds were compared page by page during the migration with
`scripts/url-diff.mjs`:

```
identical: 2368, missing: 0, accepted: 110, added: 3292
```

The 110 accepted ones are all redirected in `cloudflare/_redirects`: the
localized copies of the English-only API and Runner docs, the Docusaurus search
page, and `/1.27/`, `/runner/3/`, `/api/1.27/` which are aliases of the versions
served at the product root. The added ones are the API operation and tag pages
plus the routes the Starlight language fallback serves in English when a
translation is missing — Docusaurus answered those with a 404.

## Workflows

- `checks` builds the site and type checks it on every pull request
- `Build and Publish Docs site` publishes `sites/docs/dist` to S3/CloudFront and
  to Cloudflare Pages, and copies `cloudflare/_headers` and
  `cloudflare/_redirects` into the deployment
- `update swagger files` and `update runner reference` are unchanged, they only
  touch content
- `make cut-version PRODUCT=docs VERSION=1.28` replaces
  `docusaurus docs:version`

## Removed

`docusaurus.config.js`, the swizzled theme under `src/`, and the Docusaurus UI
translations (`i18n/*/code.json`, `i18n/*/docusaurus-theme-classic/`). The
strings those carried — the product names, the footer column titles and the
outdated translation notice — were ported to
`sites/docs/src/config/strings.ts`; Starlight ships the rest of its interface in
both Chinese locales. The documentation content itself is untouched.

## Follow ups

- nine relative links are broken in the sources and reported by every build, the
  same ones Docusaurus warned about; `GITEA_DOCS_STRICT_LINKS=true` turns them
  into an error once they are fixed
- `cloudflare/worker.js` has to be deployed for `/enterprise/` to keep resolving
- the Algolia index has to be created and crawled before the search credentials
  are set

## Testing

```shell
make install
make serve-fast   # english, the version served at the root
make serve        # the whole matrix
make build        # 5751 pages
make check        # 0 errors
make serve-built  # build and serve, the only way to try the search locally
```

## Screenshots

<img width="1371" alt="image.png" src="attachments/69acdd77-cc89-4635-a8bd-1163a34afa86">

<img width="1810" alt="image.png" src="attachments/76212416-4825-4b4d-bf59-3e549900c96f">

<img width="1789" alt="image.png" src="attachments/7be84cda-5abe-48bd-8839-7ae1ee7806e7">

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/docs/pulls/496
Reviewed-by: bircni <[email protected]>
This commit is contained in:
Lunny Xiaoandbircni committed 2026-08-12 20:03:29 +00:00
1 parent c78e0d42a9
commit b137a0e0e1
99 files changed
+6701 -13674

No files matched your search

+21 -14
View File
@@ -1,5 +1,5 @@
name: Build and Publish Docs site name: Build and Publish Docs site
run-name: docusaurus build docs site run-name: astro build docs site
on: on:
push: push:
@@ -9,6 +9,13 @@ on:
jobs: jobs:
build-docs: build-docs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
env:
NODE_OPTIONS: --max-old-space-size=8192
# search only appears once the credentials are set, the build falls back
# to pagefind otherwise
PUBLIC_DOCSEARCH_APP_ID: ${{ secrets.DOCSEARCH_APP_ID }}
PUBLIC_DOCSEARCH_API_KEY: ${{ secrets.DOCSEARCH_API_KEY }}
PUBLIC_DOCSEARCH_INDEX_NAME: ${{ secrets.DOCSEARCH_INDEX_NAME }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
@@ -23,14 +30,13 @@ jobs:
sudo ./aws/install sudo ./aws/install
- name: prepare awesome list - name: prepare awesome list
run: | run: |
make prepare-awesome-latest prepare-awesome\#25 prepare-awesome\#24 prepare-awesome\#23 prepare-awesome\#22 make prepare-awesome-latest prepare-awesome\#27 prepare-awesome\#26 prepare-awesome\#25 prepare-awesome\#24 prepare-awesome\#23 prepare-awesome\#22
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
#- uses: tats-u/docuactions-cache@v1
- name: build site - name: build site
run: | run: make build
make build
- name: aws credential configure - name: aws credential configure
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6 uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
with: with:
@@ -39,20 +45,21 @@ jobs:
aws-region: ${{ secrets.AWS_REGION}} aws-region: ${{ secrets.AWS_REGION}}
- name: Copy files to the production website with the AWS CLI - name: Copy files to the production website with the AWS CLI
run: | run: |
aws s3 sync build/ s3://docs-gitea-com aws s3 sync sites/docs/dist/ s3://docs-gitea-com
aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_DISTRIBUTION}} --paths '/*' aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_DISTRIBUTION}} --paths '/*'
- name: Copy files to Cloudflare Pages - name: Copy files to Cloudflare Pages
env: env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: | run: |
# cloudflare/_headers is only meaningful for the Cloudflare Pages # cloudflare/_headers and cloudflare/_redirects are only meaningful for
# deployment, so it is copied into build/ here, after the S3 sync # the Cloudflare Pages deployment, so they are copied into the build
# step above has already run. If it were placed in build/ any # here, after the S3 sync above has run. If they were placed in the
# earlier, that S3 sync would publish it to S3/CloudFront and make # build any earlier, that sync would publish them to S3/CloudFront and
# it publicly accessible at /_headers there. # make them publicly readable there.
cp cloudflare/_headers build/_headers cp cloudflare/_headers sites/docs/dist/_headers
test -f build/_headers || exit 1 cp cloudflare/_redirects sites/docs/dist/_redirects
pnpm dlx wrangler@4 pages deploy build \ test -f sites/docs/dist/_headers -a -f sites/docs/dist/_redirects || exit 1
pnpm dlx wrangler@4 pages deploy sites/docs/dist \
--project-name docs-gitea-com \ --project-name docs-gitea-com \
--branch main --branch main
+16 -9
View File
@@ -11,6 +11,8 @@ concurrency:
jobs: jobs:
build-docs: build-docs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
NODE_OPTIONS: --max-old-space-size=8192
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
@@ -20,14 +22,17 @@ jobs:
cache: pnpm cache: pnpm
- name: prepare awesome list - name: prepare awesome list
run: | run: |
make prepare-awesome-latest prepare-awesome\#25 prepare-awesome\#24 prepare-awesome\#23 prepare-awesome\#22 make prepare-awesome-latest prepare-awesome\#27 prepare-awesome\#26 prepare-awesome\#25 prepare-awesome\#24 prepare-awesome\#23 prepare-awesome\#22
- name: Install dependencies
- name: install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
#- uses: tats-u/docuactions-cache@v1
- name: build site - name: build site
run: | run: make build
make build
- name: type check
run: make check
- name: deploy the preview to Cloudflare Pages - name: deploy the preview to Cloudflare Pages
id: preview id: preview
env: env:
@@ -44,11 +49,13 @@ jobs:
exit 0 exit 0
fi fi
# same as the production deployment: _headers only belongs to the # same as the production deployment: _headers and _redirects only
# Cloudflare Pages output, so it is copied in right before uploading # belong to the Cloudflare Pages output, so they are copied in right
cp cloudflare/_headers build/_headers # before uploading
cp cloudflare/_headers sites/docs/dist/_headers
cp cloudflare/_redirects sites/docs/dist/_redirects
pnpm dlx wrangler@4 pages deploy build \ pnpm dlx wrangler@4 pages deploy sites/docs/dist \
--project-name docs-gitea-com \ --project-name docs-gitea-com \
--branch "pr-$PR_NUMBER" | tee wrangler.log --branch "pr-$PR_NUMBER" | tee wrangler.log
+7 -7
View File
@@ -1,12 +1,12 @@
# Dependencies # Dependencies
/node_modules node_modules
# Production # Build output
/build sites/*/dist
sites/*/.astro
# Generated files sites/*/.cache
.docusaurus build/
.cache-loader .docusaurus/
# Misc # Misc
.DS_Store .DS_Store
+29 -9
View File
@@ -1,5 +1,3 @@
export NODE_OPTIONS := "--max-old-space-size=8192"
GITEA_AWESOME_REMOTE := https://gitea.com/gitea/awesome-gitea.git GITEA_AWESOME_REMOTE := https://gitea.com/gitea/awesome-gitea.git
GITEA_AWESOME_BRANCH := main GITEA_AWESOME_BRANCH := main
@@ -27,26 +25,41 @@ install:
pnpm install pnpm install
.PHONY: prepare-docs .PHONY: prepare-docs
prepare-docs: install prepare-awesome-latest prepare-awesome\#19 prepare-awesome\#20 prepare-awesome\#21 prepare-awesome\#22 prepare-awesome\#23 prepare-awesome\#24 prepare-docs: install prepare-awesome-latest prepare-awesome\#22 prepare-awesome\#23 prepare-awesome\#24 prepare-awesome\#25 prepare-awesome\#26 prepare-awesome\#27
.PHONY: build .PHONY: build
build: build:
pnpm run build pnpm run build
# type checks the astro site and its components
.PHONY: check
check:
pnpm run check
.PHONY: serve .PHONY: serve
serve: prepare-docs serve: prepare-docs
pnpm run start pnpm run dev
.PHONY: serve-zh # only the english docs of the version served at the root, plus the runner:
serve-zh: prepare-docs # starts in a few seconds instead of loading the whole matrix
pnpm run start -- --locale zh-cn .PHONY: serve-fast
serve-fast:
pnpm run dev:en-latest
# search is built by pagefind at build time, so it is only available on the
# built site; this serves it locally
.PHONY: serve-built
serve-built: build
pnpm run preview
# static/swagger-*.json are committed files, use update-api-docs to refresh them
.PHONY: clean .PHONY: clean
clean: clean:
rm -rf .tmp rm -rf .tmp
rm -rf static/_* rm -rf sites/docs/dist
rm -rf sites/docs/.cache
rm -rf sites/docs/node_modules/.astro
# static/swagger-*.json are committed files, use update-api-docs to refresh them
.PHONY: update-api-docs .PHONY: update-api-docs
update-api-docs: update-api-docs:
./update_api_docs.sh ./update_api_docs.sh
@@ -67,3 +80,10 @@ update-runner-docs:
.PHONY: update-runner-docs-released .PHONY: update-runner-docs-released
update-runner-docs-released: update-runner-docs-released:
./update_runner_docs.sh --released ./update_runner_docs.sh --released
# freezes the current docs or runner tree as a new version, see
# scripts/cut-version.mjs
.PHONY: cut-version
cut-version:
@test -n "$(PRODUCT)" -a -n "$(VERSION)" || { echo 'usage: make cut-version PRODUCT=docs VERSION=1.28'; exit 1; }
node scripts/cut-version.mjs $(PRODUCT) $(VERSION)
+76 -36
View File
@@ -1,65 +1,99 @@
# Gitea Docs ![badge](https://gitea.com/gitea/docs/actions/workflows/build-and-publish.yaml/badge.svg) # Gitea Docs ![badge](https://gitea.com/gitea/docs/actions/workflows/build-and-publish.yaml/badge.svg)
## How to build The sources of [docs.gitea.com](https://docs.gitea.com), built with
[Astro](https://astro.build) and [Starlight](https://starlight.astro.build).
```shell The site covers three products, all served from this repository:
make clean
make prepare-docs | Product | Content | Versions | Languages |
make build | --- | --- | --- | --- |
``` | Docs | `docs/`, `versioned_docs/`, `i18n/` | next, 1.27 … 1.22 | English, 简体中文, 繁體中文 |
| API | `static/swagger-*.json` | next, 1.27 … 1.22 | English |
| Runner | `runner-docs/`, `runner-docs_versioned_docs/` | develop, 3 … 0 | English |
The enterprise documentation is built and deployed from its own repository and
reached through `/enterprise/`, which a Cloudflare worker maintained elsewhere
routes to that deployment.
## Development ## Development
```shell ```shell
make clean make install # pnpm install
make prepare-docs make serve-fast # english, the version served at the root, starts in seconds
make serve make serve # the whole matrix, every version and language
make build # production build into sites/docs/dist
make check # type checks the site and its components
``` ```
## Test en version Search is built by Pagefind while the site is built, so it only answers on the
built site: use `make serve-built` to try it. With the Algolia credentials
(`PUBLIC_DOCSEARCH_APP_ID`, `PUBLIC_DOCSEARCH_API_KEY`) set, DocSearch is used
instead and works in `make serve` too.
`GITEA_DOCS_PRODUCTS`, `GITEA_DOCS_VERSIONS` and `GITEA_DOCS_LOCALES` (comma
separated) restrict what is built, which is what `make serve-fast` uses.
`GITEA_DOCS_STRICT_LINKS=true` turns the warnings about unresolved relative
markdown links into a build error.
`sites/docs/README.md` documents how the sources are mapped onto the site.
## Writing
Pages are plain markdown with the same frontmatter and admonitions as before:
frontmatter `slug`, `sidebar_position` and `sidebar_label` keep working, and so
do the `:::note` style admonitions and the `@version@` style release variables.
Relative `*.md` links are rewritten to urls while the site is built.
The order of the top level sidebar groups comes from `sidebars.js` (and
`versioned_sidebars/` for released versions), the label and order of every other
group from the `_category_.json` of its directory.
## Cutting a version
```shell ```shell
pnpm run start make cut-version PRODUCT=docs VERSION=1.28
make cut-version PRODUCT=runner VERSION=4
``` ```
This freezes the current tree, its translations and its sidebar. The label of
the version, its release variables (`@version@`, `@dockerVersion@`, ...) and
which version is served at the root live in
`packages/content-loader/src/products.ts` and are edited by hand afterwards.
That file is the single source of truth for the product, version and language
matrix.
## API docs ## API docs
The swagger definitions rendered under `/api-docs` live in `static/swagger-latest.json` The swagger definitions rendered under `/api/` live in
(gitea main) and `static/swagger-<minor>.json` (released versions). `static/swagger-latest.json` (gitea main) and `static/swagger-<minor>.json`
(released versions).
```shell ```shell
make update-api-docs # refresh latest + every released version make update-api-docs # refresh latest + every released version
make update-api-docs-latest # refresh only static/swagger-latest.json make update-api-docs-latest # refresh only static/swagger-latest.json
``` ```
`static/swagger-latest.json` is refreshed automatically: the `update swagger files` `static/swagger-latest.json` is refreshed automatically: the `update swagger
workflow runs every 12 hours and opens a pull request whenever gitea main changed. files` workflow runs every 12 hours and opens a pull request whenever gitea main
Released versions are updated by hand when a new gitea version is documented. changed. Released versions are updated by hand when a new gitea version is
documented.
## Runner docs ## Runner docs
The runner documentation is a second docs plugin, served under `/runner`:
| Version | Content | URL | | Version | Content | URL |
| --- | --- | --- | | --- | --- | --- |
| develop | `runner-docs/` | `/runner/develop/` | | develop | `runner-docs/` | `/runner/develop/` |
| current series | `runner-docs_versioned_docs/version-3/` | `/runner/` | | current series | `runner-docs_versioned_docs/version-3/` | `/runner/` |
| older series | `runner-docs_versioned_docs/version-2/` | `/runner/2/` | | older series | `runner-docs_versioned_docs/version-2/` | `/runner/2/` |
| archived series | `runner-docs_versioned_docs/version-1/` | `/runner/1/` |
A version directory covers a whole release series (`version-3` documents every A version directory covers a whole release series (`version-3` documents every
`3.x` release), so a patch release only needs a content update, not a new folder. `3.x` release), so a patch release only needs a content update, not a new
The UI labels a series `3.x`, derived from `runner-docs_versions.json`, so no folder. Use floating image tags (`gitea/runner:3`) and links to the runner's
version number has to be bumped anywhere on a runner release. Use floating image `main` branch in those pages to keep them valid across patch releases.
tags (`gitea/runner:3`) and links to the runner's `main` branch in those pages to
keep them valid across patch releases.
Its sidebar is written by hand: `runner-sidebars.js` for develop, and Its sidebar is written by hand: `runner-sidebars.js` for develop, and
`runner-docs_versioned_sidebars/version-<version>-sidebars.json` per documented `runner-docs_versioned_sidebars/version-<version>-sidebars.json` per documented
version. The version list lives in `runner-docs_versions.json`; the `versions`, version.
`lastVersion` and `Runner Version` dropdown entries in `docusaurus.config.js` are
built from it, so a new series only has to be cut with
`pnpm run docusaurus docs:version:runner-docs <series>`.
The pages under `reference/` are generated from the runner sources — the command The pages under `reference/` are generated from the runner sources — the command
line reference from `--help`, the example configuration from `generate-config`: line reference from `--help`, the example configuration from `generate-config`:
@@ -70,12 +104,18 @@ make update-runner-docs-released # every series, from its newest stable tag
./update_runner_docs.sh v3.0.2 runner-docs_versioned_docs/version-3/reference ./update_runner_docs.sh v3.0.2 runner-docs_versioned_docs/version-3/reference
``` ```
`--released` (what `make update-runner-docs-released` runs) needs no version list: These are refreshed automatically as well: the `update runner reference`
it regenerates every `runner-docs_versioned_docs/version-<series>/reference` workflow runs weekly and opens a pull request whenever the runner's CLI or
directory from the newest stable `v<series>.x.y` tag of `gitea/runner`, looked up example configuration changed. Generating them needs Go, since the script builds
through the Gitea API. the runner binary.
All of these pages are refreshed automatically: the `update runner reference` ## Deployment
workflow runs weekly, regenerates the develop and the released references, and
opens a pull request whenever the runner's CLI or example configuration changed. `main` is built and published by the `Build and Publish Docs site` workflow, to
Generating them needs Go, since the script builds the runner binary. S3/CloudFront and to Cloudflare Pages. `cloudflare/_headers` sets the cache
policy and `cloudflare/_redirects` keeps the urls the site used to serve; both
are copied next to the build output by that workflow.
`cloudflare/docsearch-crawler.json` is the Algolia crawler configuration.
`/enterprise/` is served by another deployment and routed to it by a Cloudflare
worker that lives outside this repository.
+15 -36
View File
@@ -1,45 +1,24 @@
# Cloudflare Pages reads this file from the root of the deployed directory # Cloudflare Pages reads this file from the root of the deployed directory. It
# (build/_headers once .gitea/workflows/build-and-publish.yaml copies it # is copied into the build output by the publish workflow, so that it is not
# in). It lives here, outside static/, so that `make clean` (which does # uploaded to S3/CloudFront where it would be publicly readable at /_headers.
# `rm -rf static/_*`) cannot delete it and so Docusaurus does not copy it
# into every locale build directory (see the locale rules below for why
# that would matter anyway).
# Docusaurus content-hashed build output (webpack chunks, css, etc). The
# filename changes whenever the content changes, so it is safe to cache
# these for a long time and mark them immutable.
/assets/*
Cache-Control: public, max-age=31536000, immutable
# docusaurus.config.js configures locales ["en-us", "zh-cn", "zh-tw"] with
# "en-us" as the default. Docusaurus only serves the default locale at the
# site root; every other locale gets a full copy of the build under a
# /<locale>/ prefix (e.g. build/zh-cn, build/zh-tw), including its own
# assets/img/images directories. Confirmed live: /zh-cn/ references
# /zh-cn/assets/css/styles.a0cbb0c4.css, and without a rule matching that
# path it is served with `cache-control: public, max-age=0,
# must-revalidate` instead of the caching below.
# #
# Use a `:locale` placeholder rather than listing "zh-cn" and "zh-tw" # Unlike the docusaurus build, astro emits a single content hashed asset
# explicitly, so this keeps working if a locale is added or removed later # directory shared by every language, so no per locale rules are needed.
# without anyone remembering to update this file. Cloudflare Pages only
# allows a single `*` splat per path, so the locale segment has to be a # Content hashed build output (js, css, fonts). The file name changes with the
# named placeholder (`:locale`, matching exactly one path segment) rather # content, so these can be cached forever.
# than a second splat; `/*/assets/*` is not a valid pattern. /_astro/*
/:locale/assets/*
Cache-Control: public, max-age=31536000, immutable Cache-Control: public, max-age=31536000, immutable
# Files under static/img and static/images keep stable filenames and can be # Files under public/img and public/images keep stable names and are replaced in
# replaced in place (same name, new content), so use a short, conservative # place, so they get a short, conservative max-age instead.
# max-age instead of immutable caching.
/img/* /img/*
Cache-Control: public, max-age=86400 Cache-Control: public, max-age=86400
/:locale/img/*
Cache-Control: public, max-age=86400
/images/* /images/*
Cache-Control: public, max-age=86400 Cache-Control: public, max-age=86400
/:locale/images/* # The pagefind index, only present when the site is built without algolia
Cache-Control: public, max-age=86400 # docsearch. Content hashed as well.
/pagefind/*
Cache-Control: public, max-age=31536000, immutable
+20
View File
@@ -0,0 +1,20 @@
# Redirects for legacy URLs and routes the site no longer serves directly.
# The api reference and the runner docs are separate products, published in
# english only. Docusaurus served an empty localized copy of them.
/zh-cn/api/* /api/:splat 301
/zh-tw/api/* /api/:splat 301
/zh-cn/runner/* /runner/:splat 301
/zh-tw/runner/* /runner/:splat 301
# The version served at the root of a product is also reachable under its
# number, which is what the release notes and old links use.
/api/1.27/* /api/:splat 301
/runner/3/* /runner/:splat 301
/1.27/* /:splat 301
# The local search plugin had a results page of its own, algolia docsearch is a
# modal opened from any page.
/search / 301
/zh-cn/search /zh-cn/ 301
/zh-tw/search /zh-tw/ 301
+32
View File
@@ -0,0 +1,32 @@
{
"_comment": "Algolia DocSearch crawler configuration. The three facets come from the docsearch:* meta tags every page carries; only the versions people read are indexed, the older ones stay reachable through the version picker.",
"index_name": "gitea",
"start_urls": [
"https://docs.gitea.com/",
"https://docs.gitea.com/next/",
"https://docs.gitea.com/zh-cn/",
"https://docs.gitea.com/zh-tw/",
"https://docs.gitea.com/runner/",
"https://docs.gitea.com/api/",
"https://docs.gitea.com/enterprise/"
],
"sitemap_urls": ["https://docs.gitea.com/sitemap-index.xml"],
"exclusion_patterns": [
"https://docs.gitea.com/1.2*/**",
"https://docs.gitea.com/*/1.2*/**",
"https://docs.gitea.com/api/1.2*/**",
"https://docs.gitea.com/runner/[0-9]/**"
],
"selectors": {
"lvl0": { "selector": "//nav[contains(@aria-labelledby,'starlight__sidebar')]//a[@aria-current='page']/ancestor::details//summary//span", "type": "xpath", "global": true, "default_value": "Documentation" },
"lvl1": ".sl-markdown-content h1, h1",
"lvl2": ".sl-markdown-content h2",
"lvl3": ".sl-markdown-content h3",
"lvl4": ".sl-markdown-content h4",
"text": ".sl-markdown-content p, .sl-markdown-content li, .sl-markdown-content td"
},
"custom_settings": {
"attributesForFaceting": ["product", "version", "language"]
},
"conversation_id": []
}
File diff suppressed because it is too large. Load diff
-8
View File
@@ -1,8 +0,0 @@
{
"outdated.message": {
"message": "当前中文文档翻译不是最新版,访问英文版本查看最新内容,或"
},
"outdated.help": {
"message": "帮助我们翻译"
}
}
@@ -57,4 +57,4 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
## 合并请求模板 ## 合并请求模板
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](usage/issue-pull-request-templates.md) 有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](issue-pull-request-templates.md)
@@ -8,6 +8,8 @@ aliases:
- /zh-cn/windows-service - /zh-cn/windows-service
--- ---
# 注册为 Windows 服务
## 准备工作 ## 准备工作
在 C:\gitea\custom\conf\app.ini 中进行了以下更改: 在 C:\gitea\custom\conf\app.ini 中进行了以下更改:
@@ -6,7 +6,7 @@ sidebar_position: 25
--- ---
## 变量 # 变量
您可以创建用户、组织和仓库级别的变量。变量的级别取决于创建它的位置。当创建变量时,变量的名称会被 您可以创建用户、组织和仓库级别的变量。变量的级别取决于创建它的位置。当创建变量时,变量的名称会被
转换为大写,在yaml文件中引用时需要使用大写。 转换为大写,在yaml文件中引用时需要使用大写。
@@ -59,4 +59,4 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
## 合并请求模板 ## 合并请求模板
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](usage/issue-pull-request-templates.md) 有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](issue-pull-request-templates.md)
@@ -57,4 +57,4 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
## 合并请求模板 ## 合并请求模板
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](usage/issue-pull-request-templates.md) 有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](issue-pull-request-templates.md)
@@ -57,4 +57,4 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
## 合并请求模板 ## 合并请求模板
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](usage/issue-pull-request-templates.md) 有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](issue-pull-request-templates.md)
@@ -57,4 +57,4 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
## 合并请求模板 ## 合并请求模板
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](usage/issue-pull-request-templates.md) 有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](issue-pull-request-templates.md)
@@ -57,4 +57,4 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
## 合并请求模板 ## 合并请求模板
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](usage/issue-pull-request-templates.md) 有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](issue-pull-request-templates.md)
@@ -57,4 +57,4 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
## 合并请求模板 ## 合并请求模板
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](usage/issue-pull-request-templates.md) 有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](issue-pull-request-templates.md)
@@ -1,34 +0,0 @@
{
"link.title.Docs": {
"message": "文档",
"description": "The title of the footer links column with title=Docs in the footer"
},
"link.title.Community": {
"message": "社区",
"description": "The title of the footer links column with title=Community in the footer"
},
"link.title.More": {
"message": "更多",
"description": "The title of the footer links column with title=More in the footer"
},
"link.item.label.Tutorial": {
"message": "教程",
"description": "The label of footer link with label=Tutorial linking to /"
},
"link.item.label.Code": {
"message": "开源代码",
"description": "The label of footer link with label=Code linking to https://github.com/go-gitea/gitea"
},
"link.item.label.Stack Overflow": {
"message": "Stack Overflow",
"description": "The label of footer link with label=Stack Overflow linking to https://stackoverflow.com/questions/tagged/gitea"
},
"link.item.label.Discord": {
"message": "Discord",
"description": "The label of footer link with label=Discord linking to https://discord.gg/gitea"
},
"link.item.label.Twitter": {
"message": "Twitter",
"description": "The label of footer link with label=Twitter linking to https://twitter.com/giteaio"
}
}
@@ -1,17 +0,0 @@
{
"title": {
"message": "Gitea"
},
"item.label.Docs": {
"message": "文档"
},
"item.label.Code": {
"message": "开源代码"
},
"item.label.Support": {
"message": "支持"
},
"item.label.Blog": {
"message": "博客"
}
}
-8
View File
@@ -1,8 +0,0 @@
{
"outdated.message": {
"message": "當前中文文檔翻譯不是最新版,請訪問英文版本查看最新內容,或"
},
"outdated.help": {
"message": "幫助我們翻譯"
}
}
@@ -8,6 +8,8 @@ aliases:
- /zh-tw/windows-service - /zh-tw/windows-service
--- ---
# 註冊為 Windows 服務
## 準備工作 ## 準備工作
在 C:\gitea\custom\conf\app.ini 中進行了以下更改: 在 C:\gitea\custom\conf\app.ini 中進行了以下更改:
@@ -6,7 +6,7 @@ sidebar_position: 25
--- ---
## 變量 # 變量
您可以建立使用者、組織和儲存庫級別的變量。變量的級別取決於建立它的位置。當建立變量時,變量的名稱會被 您可以建立使用者、組織和儲存庫級別的變量。變量的級別取決於建立它的位置。當建立變量時,變量的名稱會被
轉換為大寫,在yaml文件中引用時需要使用大寫。 轉換為大寫,在yaml文件中引用時需要使用大寫。
@@ -1,34 +0,0 @@
{
"link.title.Docs": {
"message": "文件",
"description": "The title of the footer links column with title=Docs in the footer"
},
"link.title.Community": {
"message": "社區",
"description": "The title of the footer links column with title=Community in the footer"
},
"link.title.More": {
"message": "更多",
"description": "The title of the footer links column with title=More in the footer"
},
"link.item.label.Tutorial": {
"message": "教學",
"description": "The label of footer link with label=Tutorial linking to /"
},
"link.item.label.Code": {
"message": "開源程式碼",
"description": "The label of footer link with label=Code linking to https://github.com/go-gitea/gitea"
},
"link.item.label.Stack Overflow": {
"message": "Stack Overflow",
"description": "The label of footer link with label=Stack Overflow linking to https://stackoverflow.com/questions/tagged/gitea"
},
"link.item.label.Discord": {
"message": "Discord",
"description": "The label of footer link with label=Discord linking to https://discord.gg/gitea"
},
"link.item.label.Twitter": {
"message": "Twitter",
"description": "The label of footer link with label=Twitter linking to https://twitter.com/giteaio"
}
}
@@ -1,17 +0,0 @@
{
"title": {
"message": "Gitea"
},
"item.label.Docs": {
"message": "文件"
},
"item.label.Code": {
"message": "開源程式碼"
},
"item.label.Support": {
"message": "支援"
},
"item.label.Blog": {
"message": "部落格"
}
}
+7 -45
View File
@@ -3,53 +3,15 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"docusaurus": "docusaurus", "dev": "pnpm --filter @gitea-docs/site dev",
"start": "docusaurus start", "dev:en-latest": "pnpm --filter @gitea-docs/site dev:en-latest",
"start-CSRApi": "cross-env API_SSR='false' docusaurus start", "build": "pnpm --filter @gitea-docs/site build",
"build": "docusaurus build", "preview": "pnpm --filter @gitea-docs/site preview",
"build-CSRApi": "cross-env API_SSR='false' docusaurus build", "check": "pnpm --filter @gitea-docs/site check",
"swizzle": "docusaurus swizzle", "cut-version": "node scripts/cut-version.mjs"
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "3.10.2",
"@docusaurus/faster": "3.10.2",
"@docusaurus/plugin-content-docs": "3.10.2",
"@docusaurus/preset-classic": "3.10.2",
"@easyops-cn/docusaurus-search-local": "0.55.3",
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.1",
"@mdx-js/react": "3.1.1",
"@mui/material": "9.2.0",
"clsx": "2.1.1",
"docusaurus-plugin-plausible": "0.0.5",
"prism-react-renderer": "2.4.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"redocusaurus": "2.5.2"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.10.2",
"cross-env": "10.1.0"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=22"
}, },
"packageManager": "[email protected]" "packageManager": "[email protected]"
} }
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@gitea-docs/content-loader",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./products": "./src/products.ts",
"./segments": "./src/segments.ts",
"./sidebar": "./src/sidebar.ts"
},
"dependencies": {
"tinyglobby": "0.2.15",
"yaml": "2.9.0"
},
"peerDependencies": {
"astro": "^7.0.2"
}
}
+64
View File
@@ -0,0 +1,64 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import type { Segment } from './segments.js';
/** `_category_.json`, the docusaurus per directory sidebar metadata. */
export interface CategoryMeta {
label?: string;
position?: number;
collapsed?: boolean;
link?: {
type?: string;
slug?: string;
title?: string;
description?: string;
};
}
export const categoryFile = '_category_.json';
export async function readCategoryMeta(dir: string): Promise<CategoryMeta | undefined> {
try {
return JSON.parse(await fs.readFile(path.join(dir, categoryFile), 'utf-8')) as CategoryMeta;
} catch {
return undefined;
}
}
/** Fallback label for a directory without `_category_.json`. */
export function labelFromDirname(name: string): string {
return name
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
export function slugifyLabel(label: string): string {
return label
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Route id of the index page docusaurus generates for a category.
*
* Top level categories are declared by `sidebars.js` without a slug and end up
* under `/category/<label>`; nested ones keep the slug of their
* `_category_.json`. Both are reproduced so the existing urls keep working.
*/
export function categoryRouteId(
segment: Segment,
relativeDir: string,
/** Metadata of the default language, translations do not move a category. */
meta: CategoryMeta | undefined,
): string | undefined {
const isTopLevel = !relativeDir.includes('/');
const label = meta?.label ?? labelFromDirname(relativeDir.split('/').at(-1) ?? relativeDir);
if (isTopLevel) {
return [segment.prefix, 'category', slugifyLabel(label)].filter(Boolean).join('/');
}
if (meta?.link?.type !== 'generated-index') return undefined;
const slug = (meta.link.slug ?? `/${relativeDir}`).replace(/^\/+|\/+$/g, '');
return [segment.prefix, slug].filter(Boolean).join('/');
}
+419
View File
@@ -0,0 +1,419 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { Loader, LoaderContext } from 'astro/loaders';
import { glob as tinyglob } from 'tinyglobby';
import {
categoryRouteId,
labelFromDirname,
readCategoryMeta,
type CategoryMeta,
} from './categories.js';
import { internalLinks, rewriteLinks } from './links.js';
import {
applyVariables,
extractTitle,
normalizeAdmonitions,
splitFrontmatter,
} from './markdown.js';
import {
defaultLocale,
resolveSegments,
routeIdOf,
routeIdToPath,
routeSuffix,
scopeFromEnv,
segmentOf,
type ScopeFilter,
type Segment,
} from './segments.js';
export * from './categories.js';
export * from './links.js';
export * from './markdown.js';
export * from './products.js';
export * from './segments.js';
export * from './sidebar.js';
export interface GiteaDocsLoaderOptions {
/** Repository root, the directory holding `docs/`, `versioned_docs/`, ... */
root: string | URL;
/** Restricts the loaded matrix, defaults to the environment scope. */
scope?: ScopeFilter;
/** Fails the build on a broken internal link instead of warning. */
strictLinks?: boolean;
}
/** A page read from disk, before it is rendered. */
interface ParsedPage {
segment: Segment;
/** Path of the source file, relative to the repository root. */
file: string;
/** Path of the source file, relative to the version directory. */
relative: string;
/** Directory of the source file, relative to the version directory. */
dir: string;
id: string;
title: string;
order?: number;
description?: string;
tableOfContents: boolean;
sidebarLabel?: string;
body: string;
digest: string;
}
const extensions = ['md', 'mdx'];
const concurrency = 8;
/** Path starlight expects the docs collection to live at. */
const collectionRoot = 'src/content/docs';
const sourceRepo = 'https://gitea.com/gitea/docs/src/branch/main';
const awesomeRepo = 'https://gitea.com/gitea/awesome-gitea/src/branch/main/README.md';
/** Bumped when a content transform changes, so cached entries are rebuilt. */
const transformVersion = '2';
/**
* Loads the gitea documentation straight out of the content directories, which
* keep the layout the docusaurus site used, instead of copying them into
* `src/content/docs`. Every source file is mapped to its final route by the
* product matrix, so the release and translation workflows are untouched.
*/
export function giteaDocsLoader(options: GiteaDocsLoaderOptions): Loader {
const root = typeof options.root === 'string' ? options.root : fileURLToPath(options.root);
const segments = resolveSegments(options.scope ?? scopeFromEnv());
const strictLinks = options.strictLinks ?? process.env.GITEA_DOCS_STRICT_LINKS === 'true';
// a scoped build does not know the routes of the parts it left out, so links
// between products are only checked when everything is loaded
const checkKnownLinks = segments.length === resolveSegments().length;
return {
name: 'gitea-docs-loader',
async load(context) {
const { logger, store, watcher } = context;
if (segments.length === 0) logger.warn('no content segment matched the current scope');
const started = Date.now();
const untouched = new Set(store.keys());
const patterns = segments.map(
(segment) => `${segment.dir}/**/[^_]*.{${extensions.join(',')}}`,
);
const files = (await tinyglob(patterns, { cwd: root, dot: false })).sort();
// first pass: read every file, so links can be resolved to routes before
// anything is rendered
const parsed: ParsedPage[] = [];
await forEach(files, async (file) => {
const segment = segmentOf(segments, file);
if (!segment) return;
const page = await parseFile(context, root, segment, file);
if (page) parsed.push(page);
});
const routes = new Map<Segment, Map<string, string>>();
const known = new Set<string>();
for (const page of parsed) {
let group = routes.get(page.segment);
if (!group) routes.set(page.segment, (group = new Map()));
group.set(page.relative, routeIdToPath(page.id));
known.add(routeIdToPath(page.id));
}
const categories = await resolveCategories(root, segments, parsed);
for (const category of categories) known.add(routeIdToPath(category.id));
// second pass: normalize, resolve links and render
let broken = 0;
await forEach(parsed, async (page) => {
broken += await renderPage(
context,
root,
page,
routes.get(page.segment)!,
checkKnownLinks ? known : undefined,
);
untouched.delete(page.id);
});
for (const category of categories) {
await renderCategory(context, category);
untouched.delete(category.id);
}
for (const id of untouched) store.delete(id);
const message = `loaded ${store.keys().length} pages from ${
segments.length
} content directories in ${Date.now() - started}ms`;
if (broken === 0) logger.info(message);
else if (strictLinks) throw new Error(`${broken} broken internal links found`);
else logger.warn(`${message}, ${broken} broken internal links`);
if (!watcher) return;
// dev: a change reloads everything, the second pass needs the route table
for (const segment of segments) watcher.add(path.join(root, segment.dir));
const reload = async (changed: string) => {
const file = path.relative(root, changed).split(path.sep).join('/');
if (!/\.mdx?$/.test(file) || !segmentOf(segments, file)) return;
await this.load(context);
};
watcher.on('change', reload);
watcher.on('add', reload);
watcher.on('unlink', reload);
},
};
}
/** Runs `task` over `items` with a bounded number of concurrent tasks. */
async function forEach<T>(items: T[], task: (item: T) => Promise<void>): Promise<void> {
let index = 0;
await Promise.all(
Array.from({ length: concurrency }, async () => {
while (index < items.length) await task(items[index++]!);
}),
);
}
/** Reads a source file and works out where it is served. */
async function parseFile(
context: LoaderContext,
root: string,
segment: Segment,
file: string,
): Promise<ParsedPage | undefined> {
const { generateDigest, logger } = context;
const contents = await fs.readFile(path.join(root, file), 'utf-8').catch((error: Error) => {
logger.error(`error reading ${file}: ${error.message}`);
return undefined;
});
if (contents === undefined) return undefined;
const substituted = applyVariables(contents, segment.version.variables);
const { frontmatter, body: rawBody } = splitFrontmatter(substituted);
// a translated `slug` moves the page, exactly like it did under docusaurus
const id = routeIdOf(segment, routeSuffix(segment, file, asString(frontmatter.slug)));
const { title: headingTitle, body } = extractTitle(rawBody);
const title = asString(frontmatter.title) ?? headingTitle;
if (!title) logger.warn(`no title found for ${file}`);
const relative = file.slice(segment.dir.length + 1);
const dir = path.posix.dirname(relative);
return {
segment,
file,
relative,
dir: dir === '.' ? '' : dir,
id,
title: title ?? relative.replace(/\.mdx?$/, ''),
order: typeof frontmatter.sidebar_position === 'number' ? frontmatter.sidebar_position : undefined,
description: asString(frontmatter.description),
tableOfContents: frontmatter.toc !== false,
sidebarLabel: asString(frontmatter.sidebar_label),
body,
digest: generateDigest(`${transformVersion}\u0000${substituted}`),
};
}
/** Normalizes, resolves the links of and renders a page. Returns broken links. */
async function renderPage(
context: LoaderContext,
root: string,
page: ParsedPage,
routes: Map<string, string>,
known: Set<string> | undefined,
): Promise<number> {
const { logger, parseData, renderMarkdown, store } = context;
const { segment } = page;
let broken = 0;
const report = (target: string) => {
broken += 1;
logger.warn(`broken link to ${target} in ${page.file}`);
};
let body = normalizeAdmonitions(page.body);
body = rewriteLinks(body, {
dir: page.dir,
resolve: (source) => routes.get(source),
onBroken: report,
});
if (known) {
for (const target of internalLinks(body)) {
const [pathname] = target.split('#');
if (pathname && !known.has(pathname) && !known.has(`${pathname}/`)) report(target);
}
}
const digest = context.generateDigest(`${page.digest}\u0000${broken}`);
if (store.get(page.id)?.digest === digest) return broken;
// starlight resolves locale fallbacks through the path of the entry inside
// `src/content/docs`, which the sources do not have. Give every entry a
// synthetic one matching its route and point the edit link at the real file.
const filePath = `${collectionRoot}/${page.id}.md`;
const data = await parseData({
id: page.id,
filePath,
data: {
title: page.title,
editUrl: editUrlOf(page.file),
...(page.description ? { description: page.description } : {}),
...(page.tableOfContents ? {} : { tableOfContents: false }),
gitea: {
product: segment.product.id,
version: segment.version.id,
locale: segment.locale,
prefix: segment.prefix,
dir: page.dir,
name: path.posix.basename(page.relative).replace(/\.mdx?$/, ''),
...(page.order === undefined ? {} : { order: page.order }),
},
sidebar: {
...(page.order === undefined ? {} : { order: page.order }),
...(page.sidebarLabel ? { label: page.sidebarLabel } : {}),
},
},
});
const rendered = await renderMarkdown(body, {
fileURL: pathToFileURL(path.join(root, page.file)),
});
store.set({ id: page.id, data, body, filePath, digest, rendered });
return broken;
}
/** The index page docusaurus generated for a category. */
interface ParsedCategory {
segment: Segment;
id: string;
dir: string;
title: string;
description?: string;
order?: number;
body: string;
}
/**
* Recreates the index pages docusaurus generated from `_category_.json`, so
* that `/category/installation/` and `/usage/actions/` keep resolving.
*/
async function resolveCategories(
root: string,
segments: Segment[],
pages: ParsedPage[],
): Promise<ParsedCategory[]> {
const categories: ParsedCategory[] = [];
for (const segment of segments) {
if (!segment.product.categoryPages) continue;
const segmentPages = pages.filter((page) => page.segment === segment);
const directories = new Set<string>();
for (const page of segmentPages) {
const parts = page.dir ? page.dir.split('/') : [];
for (let depth = 1; depth <= parts.length; depth += 1) {
directories.add(parts.slice(0, depth).join('/'));
}
}
// labels come from the language of the segment, routes from the english tree
const metas = new Map<string, CategoryMeta | undefined>();
const routeMetas = new Map<string, CategoryMeta | undefined>();
const englishDir = segment.version.sources[defaultLocale] ?? segment.dir;
await Promise.all(
[...directories].map(async (dir) => {
metas.set(dir, await readCategoryMeta(path.join(root, segment.dir, dir)));
routeMetas.set(
dir,
segment.locale === defaultLocale
? metas.get(dir)
: await readCategoryMeta(path.join(root, englishDir, dir)),
);
}),
);
const label = (dir: string) =>
metas.get(dir)?.label ?? labelFromDirname(dir.split('/').at(-1) ?? dir);
for (const dir of [...directories].sort()) {
const meta = metas.get(dir);
const id = categoryRouteId(segment, dir, routeMetas.get(dir));
if (!id) continue;
const childDirs = [...directories]
.filter((candidate) => path.posix.dirname(candidate) === dir)
.sort((a, b) => (metas.get(a)?.position ?? 0) - (metas.get(b)?.position ?? 0));
const childPages = segmentPages
.filter((page) => page.dir === dir)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const links: string[] = [];
for (const child of childDirs) {
const childId = categoryRouteId(segment, child, routeMetas.get(child));
if (childId) links.push(`- [${label(child)}](${routeIdToPath(childId)})`);
}
for (const page of childPages) links.push(`- [${page.title}](${routeIdToPath(page.id)})`);
const description = meta?.link?.description;
categories.push({
segment,
id,
dir,
title: meta?.link?.title ?? label(dir),
description,
order: meta?.position,
body: [description, links.join('\n')].filter(Boolean).join('\n\n'),
});
}
}
return categories;
}
async function renderCategory(context: LoaderContext, category: ParsedCategory): Promise<void> {
const { generateDigest, parseData, renderMarkdown, store } = context;
const digest = generateDigest(`${transformVersion}\u0000${category.title}\u0000${category.body}`);
if (store.get(category.id)?.digest === digest) return;
const filePath = `${collectionRoot}/${category.id}.md`;
const data = await parseData({
id: category.id,
filePath,
data: {
title: category.title,
...(category.description ? { description: category.description } : {}),
editUrl: false,
gitea: {
product: category.segment.product.id,
version: category.segment.version.id,
locale: category.segment.locale,
prefix: category.segment.prefix,
dir: path.posix.dirname(category.dir) === '.' ? '' : path.posix.dirname(category.dir),
name: category.dir.split('/').at(-1)!,
category: true,
...(category.order === undefined ? {} : { order: category.order }),
},
sidebar: { hidden: true },
},
});
store.set({
id: category.id,
data,
body: category.body,
filePath,
digest,
rendered: await renderMarkdown(category.body),
});
}
/** Edit link of a source file, relative to the repository root. */
function editUrlOf(file: string): string {
return file.endsWith('/awesome.md') ? awesomeRepo : `${sourceRepo}/${file}`;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Rewrites the relative markdown links of the sources to the urls of the built
* site. Docusaurus resolved `../administration/config-cheat-sheet.md` against
* the directory of the file it appeared in; astro serves urls, so the target
* has to be looked up in the route table of the version.
*/
export interface LinkContext {
/** Directory of the source file, relative to the version directory. */
dir: string;
/** Route of a source path, relative to the version directory. */
resolve(sourcePath: string): string | undefined;
/** Called for every link that cannot be resolved. */
onBroken(target: string): void;
}
const markdownLink = /(\]\()([^)\s]+)((?:\s+"[^"]*")?\))/g;
const referenceLink = /^(\s*\[[^\]]+\]:\s*)(\S+)(.*)$/gm;
/** Normalizes `a/b/../c` without resorting to the node path module. */
function normalize(pathname: string): string {
const parts: string[] = [];
for (const part of pathname.split('/')) {
if (part === '' || part === '.') continue;
if (part === '..') parts.pop();
else parts.push(part);
}
return parts.join('/');
}
function rewriteTarget(target: string, context: LinkContext): string {
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//') || target.startsWith('#')) {
return target;
}
const [pathname = '', hash = ''] = splitHash(target);
if (!/\.mdx?$/.test(pathname)) return target;
// docusaurus resolved a link against the directory of the file first and
// against the root of the version second, both spellings are in the sources
const route =
context.resolve(normalize(`${context.dir}/${pathname}`)) ?? context.resolve(normalize(pathname));
if (!route) {
context.onBroken(target);
return target;
}
return `${route}${hash}`;
}
function splitHash(target: string): [string, string] {
const index = target.indexOf('#');
return index === -1 ? [target, ''] : [target.slice(0, index), target.slice(index)];
}
export function rewriteLinks(body: string, context: LinkContext): string {
return body
.replace(markdownLink, (_match, open: string, target: string, close: string) =>
`${open}${rewriteTarget(target, context)}${close}`,
)
.replace(referenceLink, (_match, open: string, target: string, rest: string) =>
`${open}${rewriteTarget(target, context)}${rest}`,
);
}
/**
* Internal page links of a page, used by the link checker. Links to a file
* (images, archives, ...) are left out, they are served from `public/`.
*/
export function internalLinks(body: string): string[] {
const found: string[] = [];
for (const match of body.matchAll(markdownLink)) {
const target = match[2] ?? '';
if (!target.startsWith('/') || target.startsWith('//')) continue;
const [pathname = ''] = target.split('#');
if (pathname.split('/').at(-1)?.includes('.')) continue;
found.push(target);
}
return found;
}
+79
View File
@@ -0,0 +1,79 @@
import { parse as parseYaml } from 'yaml';
import type { VersionVariables } from './products.js';
export interface ParsedFile {
frontmatter: Record<string, unknown>;
body: string;
}
const frontmatterPattern = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
export function splitFrontmatter(contents: string): ParsedFile {
const match = contents.match(frontmatterPattern);
if (!match) return { frontmatter: {}, body: contents };
const parsed = parseYaml(match[1] ?? '') as Record<string, unknown> | null;
return { frontmatter: parsed ?? {}, body: contents.slice(match[0].length) };
}
/**
* Replaces the `@name@` placeholders with the values of the version, the same
* substitution the docusaurus markdown preprocessor did.
*/
export function applyVariables(contents: string, variables?: VersionVariables): string {
if (!variables) return contents;
let result = contents;
for (const [name, value] of Object.entries(variables)) {
result = result.replaceAll(`@${name}@`, value);
}
return result;
}
/**
* Starlight renders the page title from the frontmatter, the gitea sources
* carry it as a leading level one heading instead. Lift it into the
* frontmatter and drop it from the body so it is not rendered twice.
*/
export function extractTitle(body: string): { title?: string; body: string } {
// the first level one heading is the title, wherever it sits: a few pages
// open with an admonition before it
const match = body.match(/^#\s+(.+?)\s*$/m);
if (!match || match.index === undefined) return { body };
return {
title: match[1],
body: body.slice(0, match.index) + body.slice(match.index + match[0].length),
};
}
/**
* Docusaurus admonitions to starlight asides. The two vocabularies overlap for
* `note`, `tip`, `caution` and `danger`; `warning` and `info` have to be mapped
* and titles move from `:::note Title` to `:::note[Title]`.
*/
const asideTypes: Record<string, string> = {
note: 'note',
tip: 'tip',
info: 'note',
warning: 'caution',
caution: 'caution',
danger: 'danger',
};
export function normalizeAdmonitions(body: string): string {
return body
.split('\n')
.flatMap((line) => {
const match = line.match(/^(\s*):::([a-z]+)(?:\[([^\]]*)\])?[ \t]*(.*)$/);
if (!match) return [line];
const [, indent = '', rawType = '', bracketTitle, rest = ''] = match;
const type = asideTypes[rawType];
if (!type) return [line];
// `:::warning some text:::` appears in a few pages, split it up
const inline = rest.endsWith(':::') ? rest.slice(0, -3).trim() : undefined;
const title = bracketTitle ?? (inline === undefined ? rest.trim() : '');
const opening = `${indent}:::${type}${title ? `[${title}]` : ''}`;
if (inline === undefined) return [opening];
return inline ? [opening, `${indent}${inline}`, `${indent}:::`] : [opening, `${indent}:::`];
})
.join('\n');
}
+291
View File
@@ -0,0 +1,291 @@
/**
* Single source of truth for the product x version x language matrix.
*
* Everything else (content loading, sidebars, the version and language
* pickers, search facets and the sitemap) is derived from this file, so
* adding a version or a language is a one line change here.
*/
export type LocaleId = 'en-us' | 'zh-cn' | 'zh-tw';
export type ProductId = 'docs' | 'api' | 'runner' | 'enterprise';
/** Values substituted for `@name@` placeholders in the markdown sources. */
export interface VersionVariables {
goVersion: string;
minGoVersion: string;
minNodeVersion: string;
version: string;
sourceVersion: string;
sourceBranch: string;
dockerVersion: string;
displayVersion: string;
}
export interface VersionDef {
/** Stable identifier, e.g. `1.27`, `next`, `3`, `develop`. */
id: string;
/** Label shown in the version picker. */
label: string;
/**
* Route segment inserted after the product base. Empty for the version
* served at the product root (docs 1.27 at `/`, runner 3 at `/runner/`).
*/
path: string;
/** Marks the version served at the product root. */
latest?: boolean;
/** Starlight banner shown on every page of the version. */
banner?: 'unreleased' | 'unmaintained';
/** Content directories, relative to the repository root, per language. */
sources: Partial<Record<LocaleId, string>>;
/** OpenAPI document, relative to the repository root (api product only). */
schema?: string;
variables?: VersionVariables;
}
export interface ProductDef {
id: ProductId;
label: string;
/** Route segment shared by every version, empty for the docs product. */
base: string;
/** Languages the product is published in, in display order. */
locales: LocaleId[];
versions: VersionDef[];
/** Recreate the docusaurus `_category_.json` index pages for the product. */
categoryPages?: boolean;
/** Set for products served by another deployment (enterprise). */
externalBaseUrl?: string;
}
export const locales: Record<LocaleId, { label: string; lang: string }> = {
'en-us': { label: 'English', lang: 'en-US' },
'zh-cn': { label: '简体中文', lang: 'zh-CN' },
'zh-tw': { label: '繁體中文', lang: 'zh-TW' },
};
export const defaultLocale: LocaleId = 'en-us';
const variables: Record<string, VersionVariables> = {
next: {
goVersion: '1.26',
minGoVersion: '1.26',
minNodeVersion: '22',
version: 'main-nightly',
sourceVersion: 'main',
sourceBranch: 'main',
dockerVersion: 'nightly',
displayVersion: '1.28-dev',
},
'1.27': {
goVersion: '1.26',
minGoVersion: '1.26',
minNodeVersion: '24',
version: '1.27.1',
sourceVersion: 'v1.27.1',
sourceBranch: 'release/v1.27',
dockerVersion: '1.27.1',
displayVersion: '1.27.1',
},
'1.26': {
goVersion: '1.26',
minGoVersion: '1.26',
minNodeVersion: '22',
version: '1.26.4',
sourceVersion: 'v1.26.4',
sourceBranch: 'release/v1.26',
dockerVersion: '1.26.4',
displayVersion: '1.26.4',
},
'1.25': {
goVersion: '1.25',
minGoVersion: '1.25',
minNodeVersion: '22',
version: '1.25.5',
sourceVersion: 'v1.25.0',
sourceBranch: 'release/v1.25',
dockerVersion: '1.25.5',
displayVersion: '1.25.5',
},
'1.24': {
goVersion: '1.24',
minGoVersion: '1.24',
minNodeVersion: '22',
version: '1.24.7',
sourceVersion: 'v1.24.0',
sourceBranch: 'release/v1.24',
dockerVersion: '1.24.7',
displayVersion: '1.24.7',
},
'1.23': {
goVersion: '1.23',
minGoVersion: '1.22',
minNodeVersion: '18',
version: '1.23.8',
sourceVersion: 'v1.23.8',
sourceBranch: 'release/v1.23',
dockerVersion: '1.23.8',
displayVersion: '1.23.8',
},
'1.22': {
goVersion: '1.22',
minGoVersion: '1.22',
minNodeVersion: '18',
version: '1.22.6',
sourceVersion: 'v1.22.6',
sourceBranch: 'release/v1.22',
dockerVersion: '1.22.6',
displayVersion: '1.22.6',
},
};
/** Localized content of the gitea docs, as laid out by the docusaurus tree. */
function docsSources(version: 'current' | string): VersionDef['sources'] {
const dir = version === 'current' ? 'docs' : `versioned_docs/version-${version}`;
const i18nDir = (locale: LocaleId) =>
`i18n/${locale}/docusaurus-plugin-content-docs/${
version === 'current' ? 'current' : `version-${version}`
}`;
return {
'en-us': dir,
'zh-cn': i18nDir('zh-cn'),
'zh-tw': i18nDir('zh-tw'),
};
}
const docsProduct: ProductDef = {
id: 'docs',
label: 'Docs',
base: '',
locales: ['en-us', 'zh-cn', 'zh-tw'],
categoryPages: true,
versions: [
{
id: 'next',
label: variables.next.displayVersion,
path: 'next',
banner: 'unreleased',
sources: docsSources('current'),
variables: variables.next,
},
{
id: '1.27',
label: variables['1.27'].displayVersion,
path: '',
latest: true,
sources: docsSources('1.27'),
variables: variables['1.27'],
},
...['1.26', '1.25', '1.24', '1.23', '1.22'].map(
(id): VersionDef => ({
id,
label: variables[id]!.displayVersion,
path: id,
sources: docsSources(id),
variables: variables[id],
}),
),
],
};
/** Runner docs: one directory per release series, english only for now. */
const runnerSeries = ['3', '2', '1', '0'];
const runnerProduct: ProductDef = {
id: 'runner',
label: 'Runner',
base: 'runner',
locales: ['en-us'],
versions: [
{
id: 'develop',
label: 'develop',
path: 'develop',
banner: 'unreleased',
sources: { 'en-us': 'runner-docs' },
},
...runnerSeries.map(
(id, index): VersionDef => ({
id,
label: `${id}.x`,
path: index === 0 ? '' : id,
latest: index === 0,
sources: { 'en-us': `runner-docs_versioned_docs/version-${id}` },
}),
),
],
};
/** API reference, rendered from the swagger documents by starlight-openapi. */
const apiProduct: ProductDef = {
id: 'api',
label: 'API',
base: 'api',
locales: ['en-us'],
versions: [
{
id: 'next',
label: variables.next.displayVersion,
path: 'next',
banner: 'unreleased',
sources: {},
schema: 'static/swagger-latest.json',
},
{
id: '1.27',
label: variables['1.27'].displayVersion,
path: '',
latest: true,
sources: {},
schema: 'static/swagger-27.json',
},
...['1.26', '1.25', '1.24', '1.23', '1.22'].map(
(id): VersionDef => ({
id,
label: variables[id]!.displayVersion,
path: id,
sources: {},
schema: `static/swagger-${id.split('.')[1]}.json`,
}),
),
],
};
/** Served by a separate cloudflare pages project, linked from the header. */
const enterpriseProduct: ProductDef = {
id: 'enterprise',
label: 'Enterprise',
base: 'enterprise',
locales: ['en-us', 'zh-cn'],
versions: [],
externalBaseUrl: 'https://docs.gitea.com/enterprise',
};
export const products: ProductDef[] = [docsProduct, apiProduct, runnerProduct, enterpriseProduct];
export function getProduct(id: ProductId): ProductDef {
const product = products.find((candidate) => candidate.id === id);
if (!product) throw new Error(`unknown product: ${id}`);
return product;
}
export function getVersion(productId: ProductId, versionId: string): VersionDef {
const version = getProduct(productId).versions.find((candidate) => candidate.id === versionId);
if (!version) throw new Error(`unknown version: ${productId}@${versionId}`);
return version;
}
/**
* Route prefix of a (product, version, locale) triple, without leading or
* trailing slash: `""`, `next`, `1.26`, `zh-cn/next`, `runner/2`.
*
* The docs are the site itself and keep the language in front, as starlight
* expects. The other products are self contained, so their language goes inside
* the product: `/runner/zh-cn/3/`, not `/zh-cn/runner/3/`. Only english is
* published for them today, the layout is reserved for when that changes.
*/
export function routePrefix(product: ProductDef, version: VersionDef, locale: LocaleId): string {
const language = locale === defaultLocale ? '' : locale;
return product.base
? [product.base, language, version.path].filter(Boolean).join('/')
: [language, version.path].filter(Boolean).join('/');
}
+171
View File
@@ -0,0 +1,171 @@
import {
defaultLocale,
products,
routePrefix,
type LocaleId,
type ProductDef,
type ProductId,
type VersionDef,
} from './products.js';
/**
* One content directory of the matrix: a (product, version, language) triple
* mapped to its source directory and its route prefix.
*/
export interface Segment {
product: ProductDef;
version: VersionDef;
locale: LocaleId;
/** Source directory, relative to the repository root. */
dir: string;
/** Route prefix, without leading or trailing slash. */
prefix: string;
}
export interface ScopeFilter {
products?: ProductId[];
versions?: string[];
locales?: LocaleId[];
}
/**
* Reads the build scope from the environment. Used to build a subset of the
* matrix during development, where loading every version is wasteful.
*
* GITEA_DOCS_PRODUCTS=docs,runner GITEA_DOCS_VERSIONS=1.27 pnpm dev
*/
export function scopeFromEnv(env: NodeJS.ProcessEnv = process.env): ScopeFilter {
const list = (value: string | undefined) =>
value
?.split(',')
.map((item) => item.trim())
.filter(Boolean);
return {
products: list(env.GITEA_DOCS_PRODUCTS) as ProductId[] | undefined,
versions: list(env.GITEA_DOCS_VERSIONS),
locales: list(env.GITEA_DOCS_LOCALES) as LocaleId[] | undefined,
};
}
/** Expands the product matrix into the content directories to load. */
export function resolveSegments(filter: ScopeFilter = {}): Segment[] {
const segments: Segment[] = [];
for (const product of products) {
if (product.externalBaseUrl) continue;
if (filter.products && !filter.products.includes(product.id)) continue;
for (const version of product.versions) {
if (filter.versions && !filter.versions.includes(version.id)) continue;
for (const locale of product.locales) {
if (filter.locales && !filter.locales.includes(locale)) continue;
const dir = version.sources[locale];
if (!dir) continue;
segments.push({
product,
version,
locale,
dir,
prefix: routePrefix(product, version, locale),
});
}
}
}
return segments;
}
/** Finds the segment a repository relative file path belongs to. */
export function segmentOf(segments: Segment[], file: string): Segment | undefined {
let match: Segment | undefined;
for (const segment of segments) {
if (!file.startsWith(`${segment.dir}/`)) continue;
// longest directory wins, `docs` must not swallow `docs-foo`
if (!match || segment.dir.length > match.dir.length) match = segment;
}
return match;
}
/**
* Route id of a source file, mirroring the docusaurus routing rules:
* `index` files address their directory, a relative `slug` replaces the last
* path segment and `slug: /` addresses the root of the version.
*/
export function routeSuffix(segment: Segment, file: string, slug?: string): string[] {
const relative = file.slice(segment.dir.length + 1).replace(/\.mdx?$/, '');
const parts = relative.split('/');
const isIndex = parts.at(-1) === 'index';
if (isIndex) parts.pop();
if (slug === '/') {
parts.length = 0;
} else if (slug) {
if (!isIndex) parts.pop();
parts.push(...slug.replace(/^\/+|\/+$/g, '').split('/'));
}
return parts;
}
/**
* Route id of a source file. Translations follow the route of their english
* counterpart, a translated `slug` never moves a page, which is how docusaurus
* treated the localized trees as well.
*/
export function routeIdOf(segment: Segment, suffix: string[]): string {
// the data store rejects empty ids, starlight maps `index` to the root
return [segment.prefix, ...suffix].filter(Boolean).join('/') || 'index';
}
/** Path of a source file relative to its segment directory. */
export function relativeToSegment(segment: Segment, file: string): string {
return file.slice(segment.dir.length + 1);
}
/** Key identifying the (product, version) a segment translates. */
export function groupKeyOf(segment: Segment): string {
return `${segment.product.id}@${segment.version.id}`;
}
/**
* Works out which (product, version, language) a route belongs to. Used for the
* pages starlight builds outside of the loader, the api reference in
* particular, so they get the same pickers, banner and search facets.
*/
export function metaFromRouteId(id: string):
| { product: ProductId; version: string; locale: LocaleId; prefix: string }
| undefined {
const route = id === 'index' ? '' : id;
let best: { product: ProductId; version: string; locale: LocaleId; prefix: string } | undefined;
for (const product of products) {
if (product.externalBaseUrl) continue;
for (const version of product.versions) {
for (const locale of product.locales) {
const prefix = routePrefix(product, version, locale);
// the api pages are generated by starlight-openapi, which slugifies the
// base path: `api/1.26` is built as `api/126` and renamed afterwards
const candidates = [prefix, slugifyPrefix(prefix)];
if (prefix && !candidates.some((candidate) => route === candidate || route.startsWith(`${candidate}/`))) {
continue;
}
if (best && best.prefix.length >= prefix.length) continue;
best = { product: product.id, version: version.id, locale, prefix };
}
}
}
return best;
}
/** Same slugification github-slugger applies to a starlight-openapi base path. */
function slugifyPrefix(prefix: string): string {
return prefix
.split('/')
.map((part) => part.toLowerCase().replace(/[^\w-]/g, ''))
.join('/');
}
/** Url of a route id, mirroring the starlight slug to pathname rules. */
export function routeIdToPath(id: string): string {
if (id === '' || id === 'index') return '/';
const trimmed = id.endsWith('/index') ? id.slice(0, -'/index'.length) : id;
return `/${trimmed}/`;
}
export { defaultLocale };
+226
View File
@@ -0,0 +1,226 @@
/**
* Builds one sidebar per (product, version, language) out of the loaded pages,
* reproducing what docusaurus derived from `_category_.json` and from the
* handwritten sidebar files of the runner docs.
*/
export interface SidebarPage {
/** Route id of the page. */
id: string;
/** Url of the page. */
href: string;
title: string;
hidden: boolean;
meta: {
product: string;
version: string;
locale: string;
prefix: string;
dir: string;
name: string;
order?: number;
category?: boolean;
};
}
export interface SidebarLink {
type: 'link';
label: string;
href: string;
isCurrent: boolean;
badge: undefined;
attrs: Record<string, never>;
}
export interface SidebarGroup {
type: 'group';
label: string;
entries: SidebarEntry[];
collapsed: boolean;
badge: undefined;
}
export type SidebarEntry = SidebarLink | SidebarGroup;
/** A docusaurus sidebar file, as used by the runner docs. */
export type DocusaurusSidebarItem =
| string
| { type: 'doc'; id: string; label?: string }
| { type: 'autogenerated'; dirName: string }
| { type: 'category'; label: string; collapsed?: boolean; items: DocusaurusSidebarItem[] };
const last = Number.MAX_SAFE_INTEGER;
function link(page: SidebarPage, label = page.title): SidebarLink {
return { type: 'link', label, href: page.href, isCurrent: false, badge: undefined, attrs: {} };
}
function labelFromDirname(name: string): string {
return name
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
/**
* Builds the sidebars of every group of pages, keyed by the route prefix of the
* group. `manual` provides the docusaurus sidebar of the groups that ship one.
*/
export function buildSidebars(
pages: SidebarPage[],
manual: Map<string, DocusaurusSidebarItem[]> = new Map(),
): Map<string, SidebarEntry[]> {
const groups = new Map<string, SidebarPage[]>();
for (const page of pages) {
const list = groups.get(page.meta.prefix);
if (list) list.push(page);
else groups.set(page.meta.prefix, [page]);
}
const sidebars = new Map<string, SidebarEntry[]>();
for (const [prefix, groupPages] of groups) {
const key = `${groupPages[0]!.meta.product}@${groupPages[0]!.meta.version}`;
const items = manual.get(key);
const tree = treeOf(groupPages);
sidebars.set(prefix, items ? fromDocusaurus(items, groupPages, tree) : tree.build(''));
}
return sidebars;
}
interface Tree {
/** Entries of a directory, ordered as docusaurus ordered them. */
build(dir: string): SidebarEntry[];
/** Localized label of a directory, from its `_category_.json`. */
label(dir: string): string;
}
/** Sidebar of a directory tree, ordered by `sidebar_position` as docusaurus did. */
function treeOf(pages: SidebarPage[]): Tree {
const categories = new Map<string, SidebarPage>();
for (const page of pages) {
if (!page.meta.category) continue;
const dir = [page.meta.dir, page.meta.name].filter(Boolean).join('/');
categories.set(dir, page);
}
const childDirs = (dir: string) => {
const prefix = dir ? `${dir}/` : '';
const found = new Set<string>();
for (const page of pages) {
if (!page.meta.dir.startsWith(prefix)) continue;
const rest = page.meta.dir.slice(prefix.length);
if (!rest) continue;
found.add(prefix + rest.split('/')[0]);
}
return [...found];
};
const build = (dir: string): SidebarEntry[] => {
const entries: { sort: [number, number, string]; entry: SidebarEntry }[] = [];
for (const child of childDirs(dir)) {
const category = categories.get(child);
const label = category?.title ?? labelFromDirname(child.split('/').at(-1)!);
entries.push({
sort: [category?.meta.order ?? last, 0, label],
entry: { type: 'group', label, entries: build(child), collapsed: true, badge: undefined },
});
}
for (const page of pages) {
if (page.meta.dir !== dir || page.meta.category || page.hidden) continue;
entries.push({ sort: [page.meta.order ?? last, 1, page.title], entry: link(page) });
}
return entries
.sort(
(a, b) =>
a.sort[0] - b.sort[0] || a.sort[1] - b.sort[1] || a.sort[2].localeCompare(b.sort[2]),
)
.map((item) => item.entry);
};
const label = (dir: string) =>
categories.get(dir)?.title ?? labelFromDirname(dir.split('/').at(-1)!);
return { build, label };
}
/** Sidebar described by a docusaurus sidebar file, used by the runner docs. */
function fromDocusaurus(
items: DocusaurusSidebarItem[],
pages: SidebarPage[],
tree: Tree,
): SidebarEntry[] {
const byDocId = new Map<string, SidebarPage>();
for (const page of pages) {
byDocId.set([page.meta.dir, page.meta.name].filter(Boolean).join('/'), page);
}
const convert = (item: DocusaurusSidebarItem): SidebarEntry | undefined => {
if (typeof item === 'string') {
const page = byDocId.get(item);
return page && link(page);
}
if (item.type === 'doc') {
const page = byDocId.get(item.id);
return page && link(page, item.label ?? page.title);
}
if (item.type === 'autogenerated') {
// only reached inside a category, the caller flattens the result
return {
type: 'group',
label: tree.label(item.dirName),
entries: tree.build(item.dirName),
collapsed: true,
badge: undefined,
};
}
// a category whose only child is an autogenerated directory keeps the
// translated label of the directory, like the docusaurus sidebars did
const single =
item.items.length === 1 && typeof item.items[0] === 'object' && 'dirName' in item.items[0]
? item.items[0]
: undefined;
if (single) {
return {
type: 'group',
label: tree.label(single.dirName),
entries: tree.build(single.dirName),
collapsed: item.collapsed ?? true,
badge: undefined,
};
}
return {
type: 'group',
label: item.label,
entries: item.items.map(convert).filter((entry): entry is SidebarEntry => Boolean(entry)),
collapsed: item.collapsed ?? true,
badge: undefined,
};
};
return items.map(convert).filter((entry): entry is SidebarEntry => Boolean(entry));
}
/** Marks the entry of the current page and expands the groups leading to it. */
export function markCurrent(entries: SidebarEntry[], pathname: string): SidebarEntry[] {
return entries.map((entry) => {
if (entry.type === 'link') {
return { ...entry, isCurrent: entry.href === pathname };
}
const children = markCurrent(entry.entries, pathname);
const contains = children.some(
(child) => (child.type === 'link' && child.isCurrent) || (child.type === 'group' && !child.collapsed),
);
return { ...entry, entries: children, collapsed: entry.collapsed && !contains };
});
}
/** Flattens the sidebar into the reading order used for the previous and next links. */
export function flattenSidebar(entries: SidebarEntry[]): SidebarLink[] {
return entries.flatMap((entry) =>
entry.type === 'link' ? [entry] : flattenSidebar(entry.entries),
);
}
+3137 -11721
View File
File diff suppressed because it is too large. Load diff
+6 -2
View File
@@ -1,8 +1,12 @@
packages:
- sites/*
- packages/*
savePrefix: '' savePrefix: ''
dedupePeerDependents: false dedupePeerDependents: false
updateNotifier: false updateNotifier: false
minimumReleaseAge: 0 minimumReleaseAge: 0
allowBuilds: allowBuilds:
'@swc/core': true esbuild: true
core-js: false sharp: true
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Cuts a documentation version, the replacement for `docusaurus docs:version`.
*
* node scripts/cut-version.mjs docs 1.28 # freezes docs/ as 1.28
* node scripts/cut-version.mjs runner 4 # freezes runner-docs/ as 4
*
* For the docs it copies the english tree, every translation and the sidebar
* file; for the runner the english tree and its sidebar. The version list
* (`versions.json`, `runner-docs_versions.json`) is updated too.
*
* The label, the release variables (`@version@`, `@dockerVersion@`, ...) and
* which version is served at the root live in
* `packages/content-loader/src/products.ts` and are edited by hand; the script
* prints a reminder.
*/
import { access, cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import process from 'node:process';
const [product, version] = process.argv.slice(2);
if (!product || !version || !['docs', 'runner'].includes(product)) {
console.error('usage: node scripts/cut-version.mjs <docs|runner> <version>');
process.exit(1);
}
const root = path.resolve(import.meta.dirname, '..');
const require = createRequire(import.meta.url);
async function exists(relative) {
try {
await access(path.join(root, relative));
return true;
} catch {
return false;
}
}
/** Copies a directory, refusing to overwrite a version that was already cut. */
async function freeze(from, to) {
if (await exists(to)) {
throw new Error(`${to} already exists, remove it first to cut the version again`);
}
await mkdir(path.join(root, path.dirname(to)), { recursive: true });
await cp(path.join(root, from), path.join(root, to), { recursive: true });
console.log(` ${from} -> ${to}`);
}
/** Freezes a sidebar module as the json file the versioned sidebars use. */
async function freezeSidebar(from, to) {
if (await exists(to)) throw new Error(`${to} already exists`);
const sidebar = require(path.join(root, from));
await writeFile(path.join(root, to), `${JSON.stringify(sidebar, null, 2)}\n`);
console.log(` ${from} -> ${to}`);
}
/** Prepends the version to the list, which is ordered newest first. */
async function addToVersions(file) {
const target = path.join(root, file);
const versions = JSON.parse(await readFile(target, 'utf-8'));
if (versions.includes(version)) throw new Error(`${version} is already listed in ${file}`);
versions.unshift(version);
await writeFile(target, `${JSON.stringify(versions, null, 4)}\n`);
console.log(` ${file} now starts with ${version}`);
}
console.log(`cutting ${product} ${version}`);
if (product === 'docs') {
await freeze('docs', `versioned_docs/version-${version}`);
await freezeSidebar('sidebars.js', `versioned_sidebars/version-${version}-sidebars.json`);
for (const locale of await readdir(path.join(root, 'i18n'))) {
const from = `i18n/${locale}/docusaurus-plugin-content-docs/current`;
if (!(await exists(from))) continue;
await freeze(from, `i18n/${locale}/docusaurus-plugin-content-docs/version-${version}`);
}
await addToVersions('versions.json');
} else {
await freeze('runner-docs', `runner-docs_versioned_docs/version-${version}`);
await freezeSidebar(
'runner-sidebars.js',
`runner-docs_versioned_sidebars/version-${version}-sidebars.json`,
);
await addToVersions('runner-docs_versions.json');
}
console.log(
`\nnow edit packages/content-loader/src/products.ts: add ${version} to the ${product} versions,` +
' give it a label and its release variables, and decide which version is served at the root.',
);
+120
View File
@@ -0,0 +1,120 @@
# Astro + Starlight site
Renders the gitea documentation with [Starlight](https://starlight.astro.build):
the docs (6 versions x 3 languages), the runner docs (4 series) and the api
reference (7 swagger documents). The enterprise docs stay in their own
deployment and are linked from the header.
The content directories are the ones the site has always used — `docs/`,
`versioned_docs/`, `i18n/`, `runner-docs/`, `runner-docs_versioned_docs/` and
`static/swagger-*.json` — so the release scripts and the translation workflow
stay unchanged.
## Running it
Everything is driven from the repository root, see the `README.md` there:
```shell
make serve-fast # english, the version served at the root, starts in seconds
make serve # whole matrix, api included
make serve-built # build and serve it, the only way to try the search locally
make build
make check
```
`GITEA_DOCS_PRODUCTS`, `GITEA_DOCS_VERSIONS` and `GITEA_DOCS_LOCALES` (comma
separated) restrict the matrix, which is what `make serve-fast` uses.
## How it is put together
`packages/content-loader` holds the product matrix and the astro content loader.
`src/products.ts` is the single source of truth for product x version x language
and drives everything else: content loading, sidebars, the version and language
pickers, the version banner, the search facets and the api schemas.
| Source | Route |
| --- | --- |
| `versioned_docs/version-1.27/` | `/` |
| `docs/` | `/next/` |
| `versioned_docs/version-1.26/` | `/1.26/` |
| `i18n/zh-cn/docusaurus-plugin-content-docs/version-1.27/` | `/zh-cn/` |
| `runner-docs_versioned_docs/version-3/` | `/runner/` |
| `runner-docs/` | `/runner/develop/` |
| `static/swagger-27.json` | `/api/` |
| `static/swagger-latest.json` | `/api/next/` |
The loader reads the markdown in two passes. The first one works out the route
of every file, the second normalizes and renders it:
- substitutes the version variables (`@version@`, `@dockerVersion@`, ...)
- lifts the leading `# heading` into the starlight `title`
- maps `sidebar_position` to `sidebar.order` and honours the docusaurus `slug`,
including the translations that moved a page
- rewrites the relative `*.md` links to urls, resolving them against the file
and against the version root, and reports the ones that do not resolve
- turns the docusaurus admonitions into starlight asides (`:::warning` to
`:::caution`, `:::info` to `:::note`, `:::note Title` to `:::note[Title]`)
- recreates the index pages of `_category_.json` (`/category/installation/`,
`/usage/actions/`)
Sidebars are built per (product, version, language) from `_category_.json` and
from the docusaurus sidebar files (`sidebars.js`, `versioned_sidebars/`,
`runner-sidebars.js`, `runner-docs_versioned_sidebars/`), and selected in
`src/routeData.ts`, since starlight only supports a single static sidebar.
Set `GITEA_DOCS_STRICT_LINKS=true` to fail the build on a broken internal link
instead of warning.
## Products and languages
The docs are the site itself, so their language comes first: `/zh-cn/1.26/`.
The runner and the api are separate products and keep their language inside the
product, `/runner/zh-cn/3/`, which is reserved: both are published in english
only today. Starlight builds a fallback page in every configured language for
every english page, so `/zh-cn/runner/` would exist; the post build integration
removes those directories and `cloudflare/_redirects` sends them to the
english page.
The version served at the root of a product has no number in its url: docs 1.27
is `/`, runner 3 is `/runner/` and api 1.27 is `/api/`. `/1.27/`, `/runner/3/`
and `/api/1.27/` are redirected onto them in `cloudflare/_redirects`.
## Api reference
`starlight-openapi` generates a page per operation from the seven swagger
documents `update_api_docs.sh` maintains. Two adjustments happen at build time:
- the documents set `basePath` to the full `https://gitea.com/api/v1` url, which
redoc accepted but is not valid swagger 2.0. A normalized copy is written to
`.cache/openapi/` instead of touching the sources.
- the plugin slugifies the base path, so `/api/1.26/` is generated as
`/api/126/`. `src/integrations/postbuild.ts` renames the directories and
rewrites the links once the build is done.
## Search
Algolia docsearch, enabled when `PUBLIC_DOCSEARCH_APP_ID` and
`PUBLIC_DOCSEARCH_API_KEY` are set, pagefind otherwise, so a fork without
credentials still gets search. Every page carries `docsearch:product`,
`docsearch:version` and `docsearch:language` meta tags; the crawler turns them
into facets and `src/config/docsearch.ts` filters the search on the product,
version and language being read. `cloudflare/docsearch-crawler.json` holds the
crawler configuration and only indexes the versions people read.
## Deployment
`cloudflare/_headers` and `cloudflare/_redirects` are copied next to the build
output by the publish workflow. `/enterprise/*` is routed to the enterprise
pages project by a Cloudflare worker maintained outside this repository.
## Differences between `pnpm dev` and the deployed site
- `/api/1.26/` is served at `/api/126/`, the renaming happens after the build.
- `/zh-cn/runner/` still answers, the fallback pages are removed after the build.
- `/1.27/`, `/runner/3/` and `/api/1.27/` are cloudflare redirects, so they only
work on the deployed site.
- search is built by pagefind at build time and only answers on the built site,
unless the algolia docsearch credentials are set; `make serve-built` builds and
serves it locally.
- a scoped run only builds part of the matrix, so the products and versions left
out answer with the 404 page; `pnpm dev` serves everything.
+141
View File
@@ -0,0 +1,141 @@
// @ts-check
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import starlight from '@astrojs/starlight';
import { defineConfig } from 'astro/config';
import starlightDocSearch from '@astrojs/starlight-docsearch';
import starlightOpenAPI, { openAPISidebarGroups } from 'starlight-openapi';
import { apiSchemas } from './src/config/api.js';
import { analytics, announcement, announcementStorageKey } from './src/config/site.js';
import { giteaApiSidebar } from './src/integrations/api.js';
import { giteaPostBuild } from './src/integrations/postbuild.js';
const repoRoot = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../..');
// algolia docsearch replaces the local search once the credentials are set; the
// build falls back to pagefind so that a fork without them still gets search
const useDocSearch = Boolean(
process.env.PUBLIC_DOCSEARCH_APP_ID && process.env.PUBLIC_DOCSEARCH_API_KEY,
);
export default defineConfig({
site: 'https://docs.gitea.com',
trailingSlash: 'always',
// images, logos and the swagger documents are served from `static/`
publicDir: path.join(repoRoot, 'static'),
// the markdown sources live outside this package, allow the dev server to read them
vite: {
server: { fs: { allow: [repoRoot] } },
plugins: [
{
// The api overview of starlight-openapi lists every operation of the
// document, which repeats the sidebar; swap that section for the tag
// cards of src/components/ApiOverviewTags.astro. The plugin has no
// option for it, so the import is redirected instead.
name: 'gitea-openapi-overview',
enforce: 'pre',
resolveId(source, importer) {
if (
source === './OverviewNavigationLinks.astro' &&
importer?.includes('starlight-openapi/components/overview/OverviewSchema.astro')
) {
return path.join(repoRoot, 'sites/docs/src/components/ApiOverviewTags.astro');
}
return null;
},
},
],
},
// languages the sources tag code blocks with that shiki does not know
markdown: {
shikiConfig: {
langAlias: {
apacheconf: 'apache',
conf: 'ini',
curl: 'bash',
gitignore: 'text',
none: 'text',
plantuml: 'text',
tmpl: 'handlebars',
},
},
},
integrations: [
giteaPostBuild(),
starlight({
title: 'Gitea Documentation',
description: 'Git with a cup of tea',
favicon: '/img/favicon.png',
logo: { src: './src/assets/gitea.svg', alt: 'Gitea', replacesTitle: true },
defaultLocale: 'root',
locales: {
root: { label: 'English', lang: 'en-US' },
'zh-cn': { label: '简体中文', lang: 'zh-CN' },
'zh-tw': { label: '繁體中文', lang: 'zh-TW' },
},
plugins: [
starlightOpenAPI(apiSchemas),
// after starlight-openapi, so it sees the groups the plugin built
giteaApiSidebar(),
...(useDocSearch
? [starlightDocSearch({ clientOptionsModule: './src/config/docsearch.ts' })]
: []),
],
// one sidebar per product, version and language, selected in the route
// middleware; starlight only supports a single static one
customCss: ['./src/styles/theme.css', './src/styles/custom.css'],
components: {
Header: './src/components/Header.astro',
LanguageSelect: './src/components/LanguageSelect.astro',
MarkdownContent: './src/components/MarkdownContent.astro',
SiteTitle: './src/components/SiteTitle.astro',
PageFrame: './src/components/PageFrame.astro',
},
head: [
{
// hide a dismissed announcement before the first paint, so the page
// does not jump once the stylesheet and the scripts have loaded
tag: 'script',
content: `try{if(localStorage.getItem(${JSON.stringify(
announcementStorageKey,
)})===${JSON.stringify(
announcement.id,
)})document.documentElement.dataset.giteaAnnouncement='dismissed'}catch(e){}`,
},
{
tag: 'script',
attrs: { async: true, src: `https://www.googletagmanager.com/gtag/js?id=${analytics.gtagId}` },
},
{
tag: 'script',
content: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${analytics.gtagId}');`,
},
{
tag: 'script',
attrs: {
defer: true,
'data-domain': analytics.plausibleDomain,
src: 'https://plausible.io/js/script.js',
},
},
{ tag: 'meta', attrs: { property: 'og:logo', content: '/img/gitea.svg' } },
{
tag: 'meta',
attrs: {
name: 'keywords',
content:
'gitea, git, devops, actions, packages, documentation, self-hosted, open-source, version control, gitlab, github',
},
},
],
// the api groups are needed here so starlight-openapi pages get a sidebar,
// the route middleware narrows it down to the version being read
sidebar: [...openAPISidebarGroups],
routeMiddleware: './src/routeData.ts',
// the sources live outside src/content/docs, starlight only runs its
// markdown transforms (asides, heading anchors) on files below these
markdown: { processedDirs: ['../..'] },
pagefind: !useDocSearch,
}),
],
});
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@gitea-docs/site",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"dev:en-latest": "GITEA_DOCS_PRODUCTS=docs,runner GITEA_DOCS_VERSIONS=1.27,3 GITEA_DOCS_LOCALES=en-us astro dev",
"build": "astro build",
"preview": "astro preview",
"check": "astro check"
},
"dependencies": {
"@astrojs/starlight": "0.41.7",
"@astrojs/starlight-docsearch": "0.7.0",
"@gitea-docs/content-loader": "workspace:*",
"astro": "7.2.0",
"sharp": "0.34.5",
"starlight-openapi": "0.26.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"typescript": "^5.9.3"
}
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="main_outline" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" viewBox="0 0 640 640" style="enable-background:new 0 0 640 640;" xml:space="preserve">
<g>
<path id="teabag" style="fill:#FFFFFF" d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8
c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4
c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"/>
<g>
<g>
<path style="fill:#609926" d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2
c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5
c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5
c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3
c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1
C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4
c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7
S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55
c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8
l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"/>
<path style="fill:#609926" d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4
c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1
c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9
c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3
c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3
c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29
c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8
C343.2,346.5,335,363.3,326.8,380.1z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,76 @@
---
import { Card, CardGrid } from '@astrojs/starlight/components';
/**
* Replaces the "Operations" section of the api overview, which lists every
* operation of the document and duplicates the sidebar one for one — 484 rows
* on the gitea api. Each tag gets a card linking to its own page instead.
*
* Substituted for `starlight-openapi/components/overview/OverviewNavigationLinks.astro`
* by the `gitea-openapi-overview` vite plugin in `astro.config.mjs`, so the
* props are the ones that component receives.
*/
interface NavigationGroup {
label: string;
links: { href: string; label: string; method?: string; path?: string }[];
operationTag?: { name: string; description?: string };
type: 'operations' | 'webhooks';
}
const { groups } = Astro.props as { groups: NavigationGroup[] };
const visible = groups.filter((group) => group.links.length > 0);
/**
* Page of a tag, `/api/operations/tags/repository/`. Derived from the first
* operation link of the group, which is `<base>/operations/<operation>/`, so it
* works for every version without knowing the base path here.
*/
function tagHref(group: NavigationGroup): string | undefined {
if (group.type !== 'operations' || !group.operationTag) return undefined;
const first = group.links[0]?.href;
const base = first?.slice(0, first.indexOf('operations/'));
if (!base) return undefined;
const slug = group.operationTag.name
.toLowerCase()
.replace(/[^\w-]+/g, '-')
.replace(/^-+|-+$/g, '');
return `${base}operations/tags/${slug}/`;
}
function count(group: NavigationGroup): string {
const total = group.links.length;
return `${total} ${total === 1 ? 'operation' : 'operations'}`;
}
---
{
visible.length > 0 && (
<div class="gitea-api-overview">
<h2 id="operations">Operations</h2>
<CardGrid>
{visible.map((group) => {
const href = tagHref(group);
return (
<Card title={group.label} icon="open-book">
{group.operationTag?.description && <p>{group.operationTag.description}</p>}
<p class="gitea-api-overview-count">
{href ? <a href={href}>{count(group)}</a> : count(group)}
</p>
</Card>
);
})}
</CardGrid>
</div>
)
}
<style>
.gitea-api-overview {
margin-top: 3.25rem;
}
.gitea-api-overview-count {
font-size: var(--sl-text-sm);
color: var(--sl-color-gray-3);
}
</style>
+129
View File
@@ -0,0 +1,129 @@
---
import Default from '@astrojs/starlight/components/Header.astro';
import { t } from '../config/strings';
import { announcement, announcementStorageKey } from '../config/site';
/**
* The announcement strip belongs above everything, not above the content
* column, so it is rendered inside the fixed header bar. `--sl-nav-height`
* accounts for its height in `src/styles/custom.css`, which is what starlight
* derives the offset of the sidebar, the content and the table of contents
* from, so nothing ends up underneath it.
*
* Dismissing it is remembered per announcement id; the inline script in
* `astro.config.mjs` applies that before the first paint, so the page does not
* jump.
*/
const strings = t(Astro.locals.starlightRoute.entry.data.gitea?.locale ?? 'en-us');
---
<div class="gitea-header">
<gitea-announcement
class="gitea-announcement"
data-id={announcement.id}
data-storage-key={announcementStorageKey}
data-pagefind-ignore
>
<a href={announcement.href}>{announcement.text}</a>
<button type="button" aria-label={strings.dismissAnnouncement} title={strings.dismissAnnouncement}>
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"
></path>
</svg>
</button>
</gitea-announcement>
<div class="gitea-header-main">
<Default />
</div>
</div>
<script>
class GiteaAnnouncement extends HTMLElement {
constructor() {
super();
this.querySelector('button')?.addEventListener('click', () => {
const { id, storageKey } = this.dataset;
document.documentElement.dataset.giteaAnnouncement = 'dismissed';
try {
if (id && storageKey) localStorage.setItem(storageKey, id);
} catch {
// storage can be unavailable, the strip stays dismissed for this page
}
});
}
}
customElements.define('gitea-announcement', GiteaAnnouncement);
</script>
<style>
.gitea-header {
display: flex;
flex-direction: column;
height: 100%;
}
.gitea-announcement {
flex: none;
display: flex;
align-items: center;
height: var(--gitea-announcement-height);
padding-inline: 1rem;
background-color: var(--sl-color-accent);
color: var(--sl-color-text-invert);
font-size: var(--sl-text-xs);
}
.gitea-announcement a {
flex: 1;
min-width: 0;
text-align: center;
color: inherit;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gitea-announcement a:hover {
text-decoration: underline;
}
.gitea-announcement button {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
margin-inline-start: 0.5rem;
padding: 0;
border: 0;
border-radius: 0.25rem;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.8;
}
.gitea-announcement button:hover {
opacity: 1;
background-color: rgba(255, 255, 255, 0.15);
}
/* the navigation row carries the horizontal padding of the header bar, and
reserves room for the mobile menu button where starlight shows one */
.gitea-header-main {
flex: 1;
min-height: 0;
padding-inline: var(--sl-nav-pad-x);
}
@media (max-width: 49.999rem) {
:global([data-has-sidebar]) .gitea-header-main {
padding-inline-end: calc(
var(--sl-nav-gap) + var(--sl-nav-pad-x) + var(--sl-menu-button-size)
);
}
}
</style>
@@ -0,0 +1,72 @@
---
import Select from '@astrojs/starlight/components/Select.astro';
import type { LocaleId } from '@gitea-docs/content-loader';
import { switchHref } from '../lib/routes';
import { t } from '../config/strings';
import { languagePicker, versionPicker } from '../lib/versions';
/**
* Version and language pickers. Both live in the language select slot because
* that is the only header slot starlight exposes for them; the version list
* comes from the product matrix and the language list only offers the languages
* the current product is published in, so `/api/` never offers chinese.
*/
const meta = Astro.locals.starlightRoute.entry.data.gitea;
const pathname = Astro.url.pathname;
const strings = t(meta?.locale ?? 'en-us');
const versions = meta
? versionPicker(meta).map((item) => ({
label: item.label,
value: switchHref(pathname, meta, { version: item.id }),
selected: item.current,
}))
: [];
const languages = meta
? languagePicker(meta).map((item) => ({
label: item.label,
value: switchHref(pathname, meta, { locale: item.id as LocaleId }),
selected: item.current,
}))
: [];
---
<gitea-picker>
{versions.length > 1 && <Select icon="down-caret" label={strings.versionLabel} options={versions} width="8em" />}
{languages.length > 1 && <Select icon="translate" label={strings.languageLabel} options={languages} width="7em" />}
</gitea-picker>
<a class="gitea-sign-in" href="https://gitea.com/user/login">{strings.signIn}</a>
<script>
class GiteaPicker extends HTMLElement {
constructor() {
super();
for (const select of this.querySelectorAll('select')) {
select.addEventListener('change', (event) => {
const target = event.currentTarget;
if (target instanceof HTMLSelectElement) window.location.href = target.value;
});
// A page restored from the back/forward cache keeps the selection the
// user made before navigating away, which then no longer matches the
// page being shown. Put it back on the option the server marked.
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return;
const marked = select.querySelector('option[selected]');
const index = marked instanceof HTMLOptionElement ? marked.index : 0;
if (select.selectedIndex !== index) select.selectedIndex = index;
});
}
}
}
customElements.define('gitea-picker', GiteaPicker);
</script>
<style>
gitea-picker {
display: flex;
gap: 0.5rem;
align-items: center;
}
</style>
@@ -0,0 +1,38 @@
---
import Default from '@astrojs/starlight/components/MarkdownContent.astro';
import { t } from '../config/strings';
/**
* Translated pages carry the notice the docusaurus site showed: a translation
* can lag behind the english original, so point at it and at the translation
* guide. Fallback pages already get starlight's own notice.
*/
const route = Astro.locals.starlightRoute;
const meta = route.entry.data.gitea;
const translated = Boolean(meta) && meta!.locale !== 'en-us' && !route.isFallback;
const editUrl = route.editUrl?.href;
const strings = t(meta?.locale ?? 'en-us');
---
{
translated && (
<div class="gitea-translation-notice" data-pagefind-ignore>
{strings.translationNotice}
{editUrl && <a href={editUrl}>{strings.translationHelp}</a>}
</div>
)
}
<Default><slot /></Default>
<style>
.gitea-translation-notice {
margin-bottom: 1rem;
padding: 0.5rem 0.75rem;
border-inline-start: 3px solid var(--sl-color-orange);
background-color: var(--sl-color-gray-6);
font-size: var(--sl-text-sm);
}
.gitea-translation-notice a {
margin-inline-start: 0.25rem;
}
</style>
+18
View File
@@ -0,0 +1,18 @@
---
import Default from '@astrojs/starlight/components/PageFrame.astro';
import SiteFooter from './SiteFooter.astro';
/**
* The gitea site footer closes the page, below the sidebar as well as the
* content and the table of contents. It is rendered outside the starlight page
* frame, which is a `min-height: 100vh` column, so the footer is only reached
* at the very bottom of the document.
*/
---
<Default>
<Fragment slot="header"><slot name="header" /></Fragment>
<Fragment slot="sidebar"><slot name="sidebar" /></Fragment>
<slot />
</Default>
<SiteFooter />
@@ -0,0 +1,74 @@
---
import { t } from '../config/strings';
import { footerLinks } from '../config/site';
/** The gitea site footer, rendered below every part of the page. */
const strings = t(Astro.locals.starlightRoute.entry.data.gitea?.locale ?? 'en-us');
---
<footer class="gitea-footer" data-pagefind-ignore>
<div class="columns">
{
footerLinks.map((column) => (
<div>
<p class="title">{strings.footer[column.title] ?? column.title}</p>
<ul>
{column.items.map((item) => (
<li>
<a href={item.href}>{item.label}</a>
</li>
))}
</ul>
</div>
))
}
</div>
</footer>
<style>
.gitea-footer {
padding: 2rem var(--sl-content-pad-x) 2.5rem;
border-top: 1px solid var(--sl-color-hairline);
background-color: var(--sl-color-bg-sidebar);
font-size: var(--sl-text-sm);
}
/* On wide viewports the navigation sidebar is fixed and reaches the bottom of
the viewport, so the footer is painted over it to close the whole page.
Below that width the sidebar is an overlay menu and must stay on top. */
@media (min-width: 50rem) {
.gitea-footer {
position: relative;
z-index: calc(var(--sl-z-index-menu) + 1);
}
}
/* the columns are centred as a block, with each list left aligned inside it */
.columns {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2.5rem 6rem;
max-width: 60rem;
margin-inline: auto;
}
.title {
font-weight: 600;
color: var(--sl-color-white);
margin: 0 0 0.5rem;
}
.gitea-footer ul {
list-style: none;
padding: 0;
margin: 0;
}
.gitea-footer li {
margin: 0.25rem 0;
}
.gitea-footer a {
color: var(--sl-color-gray-3);
text-decoration: none;
}
.gitea-footer a:hover {
color: var(--sl-color-white);
}
</style>
+58
View File
@@ -0,0 +1,58 @@
---
import Default from '@astrojs/starlight/components/SiteTitle.astro';
import { t } from '../config/strings';
import { productNav } from '../lib/versions';
/**
* The starlight header has no navigation links, the gitea site needs the four
* products next to the logo. Rendered next to the site title so the rest of the
* header (search, theme, pickers) keeps working untouched.
*/
const meta = Astro.locals.starlightRoute.entry.data.gitea;
const strings = t(meta?.locale ?? 'en-us');
const products = productNav(meta);
---
<div class="gitea-site-title">
<Default><slot /></Default>
<nav class="gitea-nav" aria-label="Products">
{
products.map((product) => (
<a href={product.href} class:list={[{ current: product.current }]}>
{strings.products[product.id] ?? product.label}
</a>
))
}
</nav>
</div>
<style>
.gitea-site-title {
display: flex;
align-items: center;
gap: 1rem;
overflow: hidden;
}
.gitea-nav {
display: none;
gap: 0.75rem;
font-size: var(--sl-text-sm);
}
.gitea-nav a {
color: var(--sl-color-gray-2);
text-decoration: none;
white-space: nowrap;
}
.gitea-nav a:hover,
.gitea-nav a.current {
color: var(--sl-color-white);
}
.gitea-nav a.current {
font-weight: 600;
}
@media (min-width: 72rem) {
.gitea-nav {
display: flex;
}
}
</style>
+142
View File
@@ -0,0 +1,142 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { getProduct } from '@gitea-docs/content-loader';
import { apiTagDescriptions } from './apiTags';
import { repoRoot } from './paths';
const cacheDir = path.join(repoRoot, 'sites/docs/.cache/openapi');
/**
* `listAdminWorkflowJobs` to `list-admin-workflow-jobs`. starlight-openapi
* builds the url of an operation page by slugifying its operation id, and
* slugifying a camel case identifier just drops the word boundaries, which
* gives `/api/operations/listadminworkflowjobs/`. Acronyms are kept together,
* so `getGPGKey` becomes `get-gpg-key` and `userGetOAuth2Application` becomes
* `user-get-oauth2-application`.
*/
export function kebabCase(value: string): string {
return value
.replace(/([a-z\d])([A-Z])/g, '$1-$2')
.replace(/([A-Z]{2,})([A-Z][a-z])/g, '$1-$2')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase();
}
interface Operation {
operationId?: string;
}
interface SwaggerDocument {
basePath?: string;
host?: string;
schemes?: string[];
tags?: { name: string; description?: string }[];
paths?: Record<string, Record<string, Operation | unknown>>;
}
const httpMethods = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch'];
/**
* Hyphenates the operation ids of a document, which is what the urls of the
* operation pages are built from.
*
* Gitea has operation ids that only differ in case, `userGetOauth2Application`
* (the list) and `userGetOAuth2Application` (a single one). They already
* collide today, since the plugin lowercases them, and one of the two pages
* silently overwrites the other. A colliding id is suffixed with the last path
* parameter, so the two become `user-get-oauth2-application` and
* `user-get-oauth2-application-by-id`.
*/
function hyphenateOperationIds(document: SwaggerDocument): void {
const used = new Set<string>();
for (const [pathname, pathItem] of Object.entries(document.paths ?? {})) {
for (const [method, value] of Object.entries(pathItem)) {
if (!httpMethods.includes(method)) continue;
const operation = value as Operation;
if (!operation?.operationId) continue;
const base = kebabCase(operation.operationId);
let id = base;
if (used.has(id)) {
const parameter = [...pathname.matchAll(/\{([^}]+)\}/g)].at(-1)?.[1];
if (parameter) id = `${base}-by-${kebabCase(parameter)}`;
for (let suffix = 2; used.has(id); suffix += 1) id = `${base}-${suffix}`;
}
used.add(id);
operation.operationId = id;
}
}
}
/**
* `update_api_docs.sh` rewrites `basePath` of the swagger documents to the full
* `https://gitea.com/api/v1` url, which redoc accepted but is not valid swagger
* 2.0: the host belongs into `host` and the scheme into `schemes`. Normalize a
* copy instead of touching the sources, which the docusaurus site still reads.
*/
function normalizeSchema(source: string, name: string): string {
const document = JSON.parse(readFileSync(source, 'utf-8')) as SwaggerDocument;
if (document.basePath && /^https?:\/\//.test(document.basePath)) {
const url = new URL(document.basePath);
document.host = url.host;
document.schemes = [url.protocol.replace(':', '')];
document.basePath = url.pathname;
}
// the operation id is only used to build the url of the operation page, it is
// not rendered anywhere, so hyphenating it only makes the urls readable
hyphenateOperationIds(document);
describeTags(document);
mkdirSync(cacheDir, { recursive: true });
const target = path.join(cacheDir, name);
writeFileSync(target, JSON.stringify(document));
return target;
}
/**
* Gives every tag used by the document a description, so starlight-openapi
* builds a landing page for it and the api overview can link to those instead
* of listing every operation.
*/
function describeTags(document: SwaggerDocument): void {
const used = new Set<string>();
for (const pathItem of Object.values(document.paths ?? {})) {
for (const value of Object.values(pathItem)) {
for (const tag of (value as { tags?: string[] })?.tags ?? []) used.add(tag);
}
}
const described = new Map(document.tags?.map((tag) => [tag.name, tag]) ?? []);
for (const name of [...used].sort()) {
const description = described.get(name)?.description ?? apiTagDescriptions[name];
if (description) described.set(name, { name, description });
}
document.tags = [...described.values()];
}
/**
* One starlight-openapi schema per documented api version, each mounted at the
* route of its version: `/api/` for the latest release, `/api/1.26/` and
* `/api/next/` for the others.
*/
export const apiSchemas = getProduct('api')
.versions.filter((version) => version.schema)
.map((version) => ({
base: ['api', version.path].filter(Boolean).join('/'),
label: `API ${version.label}`,
schema: normalizeSchema(path.join(repoRoot, version.schema!), `${version.id}.json`),
// the schema group is lifted to the top level of the sidebar in
// src/middleware/api.ts, so the tag groups below it start collapsed and
// starlight opens the one holding the current operation
sidebar: {
collapsed: true,
label: `API ${version.label}`,
// http method badges, coloured per method in src/styles/custom.css
operations: { badges: true },
},
}));
Loaded 50 of 99 files, more files were not shown because too many files have changed in this diff. Show more