mirror of
https://gitea.com/gitea/docs.git
synced 2026-09-17 19:55:34 +00:00
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:
@@ -1,5 +1,5 @@
|
||||
name: Build and Publish Docs site
|
||||
run-name: docusaurus build docs site
|
||||
run-name: astro build docs site
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -9,6 +9,13 @@ on:
|
||||
jobs:
|
||||
build-docs:
|
||||
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:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
@@ -23,14 +30,13 @@ jobs:
|
||||
sudo ./aws/install
|
||||
- name: prepare awesome list
|
||||
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
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
#- uses: tats-u/docuactions-cache@v1
|
||||
- name: build site
|
||||
run: |
|
||||
make build
|
||||
run: make build
|
||||
|
||||
- name: aws credential configure
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
|
||||
with:
|
||||
@@ -39,20 +45,21 @@ jobs:
|
||||
aws-region: ${{ secrets.AWS_REGION}}
|
||||
- name: Copy files to the production website with the AWS CLI
|
||||
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 '/*'
|
||||
- name: Copy files to Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
# cloudflare/_headers is only meaningful for the Cloudflare Pages
|
||||
# deployment, so it is copied into build/ here, after the S3 sync
|
||||
# step above has already run. If it were placed in build/ any
|
||||
# earlier, that S3 sync would publish it to S3/CloudFront and make
|
||||
# it publicly accessible at /_headers there.
|
||||
cp cloudflare/_headers build/_headers
|
||||
test -f build/_headers || exit 1
|
||||
pnpm dlx wrangler@4 pages deploy build \
|
||||
# cloudflare/_headers and cloudflare/_redirects are only meaningful for
|
||||
# the Cloudflare Pages deployment, so they are copied into the build
|
||||
# here, after the S3 sync above has run. If they were placed in the
|
||||
# build any earlier, that sync would publish them to S3/CloudFront and
|
||||
# make them publicly readable there.
|
||||
cp cloudflare/_headers sites/docs/dist/_headers
|
||||
cp cloudflare/_redirects sites/docs/dist/_redirects
|
||||
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 \
|
||||
--branch main
|
||||
|
||||
@@ -11,6 +11,8 @@ concurrency:
|
||||
jobs:
|
||||
build-docs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
@@ -20,14 +22,17 @@ jobs:
|
||||
cache: pnpm
|
||||
- name: prepare awesome list
|
||||
run: |
|
||||
make prepare-awesome-latest prepare-awesome\#25 prepare-awesome\#24 prepare-awesome\#23 prepare-awesome\#22
|
||||
- name: Install dependencies
|
||||
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
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
#- uses: tats-u/docuactions-cache@v1
|
||||
- name: build site
|
||||
run: |
|
||||
make build
|
||||
run: make build
|
||||
|
||||
- name: type check
|
||||
run: make check
|
||||
|
||||
- name: deploy the preview to Cloudflare Pages
|
||||
id: preview
|
||||
env:
|
||||
@@ -44,11 +49,13 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# same as the production deployment: _headers only belongs to the
|
||||
# Cloudflare Pages output, so it is copied in right before uploading
|
||||
cp cloudflare/_headers build/_headers
|
||||
# same as the production deployment: _headers and _redirects only
|
||||
# belong to the Cloudflare Pages output, so they are copied in right
|
||||
# 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 \
|
||||
--branch "pr-$PR_NUMBER" | tee wrangler.log
|
||||
|
||||
|
||||
+7
-7
@@ -1,12 +1,12 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
# Build output
|
||||
sites/*/dist
|
||||
sites/*/.astro
|
||||
sites/*/.cache
|
||||
build/
|
||||
.docusaurus/
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export NODE_OPTIONS := "--max-old-space-size=8192"
|
||||
|
||||
GITEA_AWESOME_REMOTE := https://gitea.com/gitea/awesome-gitea.git
|
||||
GITEA_AWESOME_BRANCH := main
|
||||
|
||||
@@ -27,26 +25,41 @@ install:
|
||||
pnpm install
|
||||
|
||||
.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
|
||||
build:
|
||||
pnpm run build
|
||||
|
||||
# type checks the astro site and its components
|
||||
.PHONY: check
|
||||
check:
|
||||
pnpm run check
|
||||
|
||||
.PHONY: serve
|
||||
serve: prepare-docs
|
||||
pnpm run start
|
||||
pnpm run dev
|
||||
|
||||
.PHONY: serve-zh
|
||||
serve-zh: prepare-docs
|
||||
pnpm run start -- --locale zh-cn
|
||||
# only the english docs of the version served at the root, plus the runner:
|
||||
# starts in a few seconds instead of loading the whole matrix
|
||||
.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
|
||||
clean:
|
||||
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
|
||||
update-api-docs:
|
||||
./update_api_docs.sh
|
||||
@@ -67,3 +80,10 @@ update-runner-docs:
|
||||
.PHONY: update-runner-docs-released
|
||||
update-runner-docs-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)
|
||||
|
||||
@@ -1,65 +1,99 @@
|
||||
# Gitea Docs 
|
||||
|
||||
## 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
|
||||
make clean
|
||||
make prepare-docs
|
||||
make build
|
||||
```
|
||||
The site covers three products, all served from this repository:
|
||||
|
||||
| Product | Content | Versions | Languages |
|
||||
| --- | --- | --- | --- |
|
||||
| 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
|
||||
|
||||
```shell
|
||||
make clean
|
||||
make prepare-docs
|
||||
make serve
|
||||
make install # pnpm install
|
||||
make serve-fast # english, the version served at the root, starts in seconds
|
||||
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
|
||||
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
|
||||
|
||||
The swagger definitions rendered under `/api-docs` live in `static/swagger-latest.json`
|
||||
(gitea main) and `static/swagger-<minor>.json` (released versions).
|
||||
The swagger definitions rendered under `/api/` live in
|
||||
`static/swagger-latest.json` (gitea main) and `static/swagger-<minor>.json`
|
||||
(released versions).
|
||||
|
||||
```shell
|
||||
make update-api-docs # refresh latest + every released version
|
||||
make update-api-docs-latest # refresh only static/swagger-latest.json
|
||||
```
|
||||
|
||||
`static/swagger-latest.json` is refreshed automatically: the `update swagger files`
|
||||
workflow runs every 12 hours and opens a pull request whenever gitea main changed.
|
||||
Released versions are updated by hand when a new gitea version is documented.
|
||||
`static/swagger-latest.json` is refreshed automatically: the `update swagger
|
||||
files` workflow runs every 12 hours and opens a pull request whenever gitea main
|
||||
changed. Released versions are updated by hand when a new gitea version is
|
||||
documented.
|
||||
|
||||
## Runner docs
|
||||
|
||||
The runner documentation is a second docs plugin, served under `/runner`:
|
||||
|
||||
| Version | Content | URL |
|
||||
| --- | --- | --- |
|
||||
| develop | `runner-docs/` | `/runner/develop/` |
|
||||
| current series | `runner-docs_versioned_docs/version-3/` | `/runner/` |
|
||||
| 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
|
||||
`3.x` release), so a patch release only needs a content update, not a new folder.
|
||||
The UI labels a series `3.x`, derived from `runner-docs_versions.json`, so no
|
||||
version number has to be bumped anywhere on a runner release. Use floating image
|
||||
tags (`gitea/runner:3`) and links to the runner's `main` branch in those pages to
|
||||
keep them valid across patch releases.
|
||||
`3.x` release), so a patch release only needs a content update, not a new
|
||||
folder. Use floating image 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
|
||||
`runner-docs_versioned_sidebars/version-<version>-sidebars.json` per documented
|
||||
version. The version list lives in `runner-docs_versions.json`; the `versions`,
|
||||
`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>`.
|
||||
version.
|
||||
|
||||
The pages under `reference/` are generated from the runner sources — the command
|
||||
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
|
||||
```
|
||||
|
||||
`--released` (what `make update-runner-docs-released` runs) needs no version list:
|
||||
it regenerates every `runner-docs_versioned_docs/version-<series>/reference`
|
||||
directory from the newest stable `v<series>.x.y` tag of `gitea/runner`, looked up
|
||||
through the Gitea API.
|
||||
These are refreshed automatically as well: the `update runner reference`
|
||||
workflow runs weekly and opens a pull request whenever the runner's CLI or
|
||||
example configuration changed. Generating them needs Go, since the script builds
|
||||
the runner binary.
|
||||
|
||||
All of these pages are refreshed automatically: the `update runner reference`
|
||||
workflow runs weekly, regenerates the develop and the released references, and
|
||||
opens a pull request whenever the runner's CLI or example configuration changed.
|
||||
Generating them needs Go, since the script builds the runner binary.
|
||||
## Deployment
|
||||
|
||||
`main` is built and published by the `Build and Publish Docs site` workflow, to
|
||||
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
@@ -1,45 +1,24 @@
|
||||
# Cloudflare Pages reads this file from the root of the deployed directory
|
||||
# (build/_headers once .gitea/workflows/build-and-publish.yaml copies it
|
||||
# in). It lives here, outside static/, so that `make clean` (which does
|
||||
# `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.
|
||||
# Cloudflare Pages reads this file from the root of the deployed directory. It
|
||||
# is copied into the build output by the publish workflow, so that it is not
|
||||
# uploaded to S3/CloudFront where it would be publicly readable at /_headers.
|
||||
#
|
||||
# Use a `:locale` placeholder rather than listing "zh-cn" and "zh-tw"
|
||||
# explicitly, so this keeps working if a locale is added or removed later
|
||||
# without anyone remembering to update this file. Cloudflare Pages only
|
||||
# allows a single `*` splat per path, so the locale segment has to be a
|
||||
# named placeholder (`:locale`, matching exactly one path segment) rather
|
||||
# than a second splat; `/*/assets/*` is not a valid pattern.
|
||||
/:locale/assets/*
|
||||
# Unlike the docusaurus build, astro emits a single content hashed asset
|
||||
# directory shared by every language, so no per locale rules are needed.
|
||||
|
||||
# Content hashed build output (js, css, fonts). The file name changes with the
|
||||
# content, so these can be cached forever.
|
||||
/_astro/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# Files under static/img and static/images keep stable filenames and can be
|
||||
# replaced in place (same name, new content), so use a short, conservative
|
||||
# max-age instead of immutable caching.
|
||||
# Files under public/img and public/images keep stable names and are replaced in
|
||||
# place, so they get a short, conservative max-age instead.
|
||||
/img/*
|
||||
Cache-Control: public, max-age=86400
|
||||
|
||||
/:locale/img/*
|
||||
Cache-Control: public, max-age=86400
|
||||
|
||||
/images/*
|
||||
Cache-Control: public, max-age=86400
|
||||
|
||||
/:locale/images/*
|
||||
Cache-Control: public, max-age=86400
|
||||
# The pagefind index, only present when the site is built without algolia
|
||||
# docsearch. Content hashed as well.
|
||||
/pagefind/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
@@ -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
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -1,542 +0,0 @@
|
||||
// @ts-check
|
||||
// Note: type annotations allow type checking and IDEs autocompletion
|
||||
|
||||
import { themes as prismThemes } from "prism-react-renderer";
|
||||
|
||||
const lightCodeTheme = prismThemes.github;
|
||||
const darkCodeTheme = prismThemes.dracula;
|
||||
|
||||
// order usage directory by type first
|
||||
function sortItemsByCategory(items) {
|
||||
// type with "category" (directory) first
|
||||
const sortedItems = items.sort(function (a, b) {
|
||||
return a.type.localeCompare(b.type);
|
||||
});
|
||||
return sortedItems;
|
||||
}
|
||||
|
||||
const renderApiSSR = process.env.API_SSR !== "false";
|
||||
|
||||
const apiConfig = [
|
||||
"redocusaurus",
|
||||
{
|
||||
// Plugin Options for loading OpenAPI files
|
||||
specs: renderApiSSR
|
||||
? [
|
||||
{
|
||||
route: "/api/next/",
|
||||
spec: "static/swagger-latest.json",
|
||||
},
|
||||
{
|
||||
route: "/api/",
|
||||
spec: "static/swagger-27.json",
|
||||
},
|
||||
{
|
||||
route: "/api/1.27/",
|
||||
spec: "static/swagger-27.json",
|
||||
},
|
||||
{
|
||||
route: "/api/1.26/",
|
||||
spec: "static/swagger-26.json",
|
||||
},
|
||||
{
|
||||
route: "/api/1.25/",
|
||||
spec: "static/swagger-25.json",
|
||||
},
|
||||
{
|
||||
route: "/api/1.24/",
|
||||
spec: "static/swagger-24.json",
|
||||
},
|
||||
{
|
||||
route: "/api/1.23/",
|
||||
spec: "static/swagger-23.json",
|
||||
},
|
||||
{
|
||||
route: "/api/1.22/",
|
||||
spec: "static/swagger-22.json",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
// Theme Options for modifying how redoc renders them
|
||||
theme: {
|
||||
// Change with your site colors
|
||||
primaryColor: "#1890ff",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const pageConfig = renderApiSSR
|
||||
? {
|
||||
exclude: ["api/**"],
|
||||
}
|
||||
: {};
|
||||
|
||||
const globalVariables = {
|
||||
"current": {
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
const versions = {
|
||||
"current": {
|
||||
label: globalVariables["current"].displayVersion, // path is kept as next for dev (so users can always find "nightly" docs)
|
||||
banner: "unreleased",
|
||||
},
|
||||
"1.27": {
|
||||
label: globalVariables["1.27"].displayVersion,
|
||||
},
|
||||
"1.26": {
|
||||
label: globalVariables["1.26"].displayVersion,
|
||||
},
|
||||
"1.25": {
|
||||
label: globalVariables["1.25"].displayVersion,
|
||||
},
|
||||
"1.24": {
|
||||
label: globalVariables["1.24"].displayVersion,
|
||||
},
|
||||
"1.23": {
|
||||
label: globalVariables["1.23"].displayVersion,
|
||||
},
|
||||
"1.22": {
|
||||
label: globalVariables["1.22"].displayVersion,
|
||||
},
|
||||
};
|
||||
|
||||
// The runner docs keep one directory per release series
|
||||
// (runner-docs_versioned_docs/version-<series>), so a series is labelled "3.x"
|
||||
// and a patch release never touches this file. The list is the one docusaurus
|
||||
// maintains, newest first.
|
||||
const runnerVersions = require("./runner-docs_versions.json");
|
||||
const runnerVersionLabel = (version) => `${version}.x`;
|
||||
const runnerVersionPath = (version) =>
|
||||
// no path for the latest series, its docs are served at /runner/
|
||||
version === runnerVersions[0] ? "/runner/" : `/runner/${version}/`;
|
||||
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
title: "Gitea Documentation",
|
||||
tagline: "Git with a cup of tea",
|
||||
url: "https://docs.gitea.com",
|
||||
baseUrl: "/",
|
||||
onBrokenLinks: "warn",
|
||||
favicon: "img/favicon.png",
|
||||
future: {
|
||||
faster: true,
|
||||
v4: true
|
||||
},
|
||||
plugins: [
|
||||
[
|
||||
"docusaurus-plugin-plausible",
|
||||
{
|
||||
domain: "docs.gitea.com",
|
||||
},
|
||||
],
|
||||
|
||||
// for runner documentations
|
||||
[
|
||||
"@docusaurus/plugin-content-docs",
|
||||
{
|
||||
id: "runner-docs",
|
||||
path: "runner-docs",
|
||||
routeBasePath: "runner",
|
||||
sidebarPath: require.resolve("./runner-sidebars.js"),
|
||||
// the current runner docs describe the main branch of gitea/runner
|
||||
includeCurrentVersion: true,
|
||||
versions: {
|
||||
current: {
|
||||
path: "develop",
|
||||
label: "develop",
|
||||
banner: "unreleased",
|
||||
},
|
||||
...Object.fromEntries(
|
||||
runnerVersions.map((version) => [
|
||||
version,
|
||||
{ label: runnerVersionLabel(version) },
|
||||
]),
|
||||
),
|
||||
},
|
||||
// the newest series has no "path", so the latest stable runner docs are
|
||||
// served at /runner/ and links do not need updating on each release
|
||||
lastVersion: runnerVersions[0],
|
||||
editUrl: ({
|
||||
versionDocsDirPath,
|
||||
docPath,
|
||||
locale,
|
||||
version,
|
||||
permalink,
|
||||
}) => {
|
||||
return `https://gitea.com/gitea/docs/src/branch/main/${
|
||||
version === "current"
|
||||
? "runner-docs"
|
||||
: `runner-docs_versioned_docs/version-${version}`
|
||||
}/${docPath}`;
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
i18n: {
|
||||
defaultLocale: "en-us",
|
||||
locales: ["en-us", "zh-cn", "zh-tw"],
|
||||
localeConfigs: {
|
||||
"en-us": {
|
||||
label: "English",
|
||||
},
|
||||
"zh-cn": {
|
||||
label: "简体中文",
|
||||
},
|
||||
"zh-tw": {
|
||||
label: "繁體中文",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
"@docusaurus/preset-classic",
|
||||
//'classic',
|
||||
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||
({
|
||||
docs: {
|
||||
sidebarPath: require.resolve("./sidebars.js"),
|
||||
routeBasePath: "/", // Serve the docs at the site's root
|
||||
editUrl: ({
|
||||
versionDocsDirPath,
|
||||
docPath,
|
||||
locale,
|
||||
version,
|
||||
permalink,
|
||||
}) => {
|
||||
// Special case for awesome page
|
||||
if (docPath.includes("awesome.md")) {
|
||||
return `https://gitea.com/gitea/awesome-gitea/src/branch/main/README.md`;
|
||||
}
|
||||
if (locale === "en-us") {
|
||||
return `https://gitea.com/gitea/docs/src/branch/main/${
|
||||
version === "current"
|
||||
? "docs"
|
||||
: `versioned_docs/version-${version}`
|
||||
}/${docPath}`;
|
||||
}
|
||||
return `https://gitea.com/gitea/docs/src/branch/main/i18n/${locale}/docusaurus-plugin-content-docs/${
|
||||
version === "current" ? "current" : `version-${version}`
|
||||
}/${docPath}`;
|
||||
},
|
||||
versions: versions,
|
||||
lastVersion: "1.27",
|
||||
async sidebarItemsGenerator({
|
||||
defaultSidebarItemsGenerator,
|
||||
...args
|
||||
}) {
|
||||
const { item } = args;
|
||||
// Use the provided data to generate a custom sidebar slice
|
||||
const sidebarItems = await defaultSidebarItemsGenerator(args);
|
||||
if (item.dirName !== "usage") {
|
||||
return sidebarItems;
|
||||
} else {
|
||||
return sortItemsByCategory(sidebarItems);
|
||||
}
|
||||
},
|
||||
},
|
||||
blog: false,
|
||||
theme: {
|
||||
customCss: require.resolve("./src/css/custom.css"),
|
||||
},
|
||||
pages: pageConfig,
|
||||
gtag: {
|
||||
trackingID: "G-KHM0KYT506",
|
||||
},
|
||||
}),
|
||||
],
|
||||
apiConfig,
|
||||
],
|
||||
markdown: {
|
||||
hooks: {
|
||||
onBrokenMarkdownLinks: "warn",
|
||||
},
|
||||
preprocessor: ({ filePath, fileContent }) => {
|
||||
var key = "";
|
||||
var found = false;
|
||||
for (key in globalVariables) {
|
||||
let folderName = key == "current" ? "current" : `version-${key}`;
|
||||
if (filePath.includes(`/${folderName}/`)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (key == "" || !found) {
|
||||
key = "current";
|
||||
}
|
||||
|
||||
let content = fileContent;
|
||||
for (const variable in globalVariables[key]) {
|
||||
content = content.replaceAll(
|
||||
"@" + variable + "@",
|
||||
globalVariables[key][variable]
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
},
|
||||
themes: [
|
||||
[
|
||||
"@easyops-cn/docusaurus-search-local",
|
||||
{
|
||||
hashed: false,
|
||||
language: ["en", "zh"],
|
||||
highlightSearchTermsOnTargetPage: true,
|
||||
explicitSearchResultPath: true,
|
||||
indexBlog: false,
|
||||
docsRouteBasePath: "/",
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig:
|
||||
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||
({
|
||||
image: '/img/gitea.svg',
|
||||
metadata: [
|
||||
{
|
||||
name: 'og:logo',
|
||||
content: '/img/gitea.svg'
|
||||
},
|
||||
{
|
||||
name: "keywords",
|
||||
content:
|
||||
"gitea, git, devops, actions, packages, documentation, self-hosted, open-source, version control, gitlab, github",
|
||||
},
|
||||
],
|
||||
colorMode: {
|
||||
defaultMode: "light",
|
||||
disableSwitch: false,
|
||||
respectPrefersColorScheme: true,
|
||||
},
|
||||
announcementBar: {
|
||||
id: "announcementBar-4", // Increment on change
|
||||
content: `<a href="https://about.gitea.com/products/cloud">Try Gitea Cloud ☁️ for 30 days <span aria-hidden="true">→</span> Accelerate your Development & Deploys!</a>`,
|
||||
},
|
||||
navbar: {
|
||||
title: "Gitea",
|
||||
logo: {
|
||||
alt: "Gitea Logo",
|
||||
src: "img/gitea.svg",
|
||||
href: "https://about.gitea.com/",
|
||||
target: "_self",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: "doc",
|
||||
docId: "index",
|
||||
position: "left",
|
||||
label: "Docs",
|
||||
},
|
||||
{
|
||||
to: "/api/",
|
||||
label: "API",
|
||||
position: "left",
|
||||
activeBaseRegex: "/api/",
|
||||
},
|
||||
{
|
||||
to: "/runner/",
|
||||
label: "Runner",
|
||||
position: "left",
|
||||
activeBaseRegex: "/runner/",
|
||||
},
|
||||
{
|
||||
position: "left",
|
||||
label: "Enterprise",
|
||||
href: "https://docs.gitea.com/enterprise",
|
||||
className: "internal-href",
|
||||
target: "_self",
|
||||
},
|
||||
{
|
||||
type: "search",
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
type: "localeDropdown",
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
type: "docsVersionDropdown",
|
||||
position: "right",
|
||||
dropdownActiveClassDisabled: true,
|
||||
},
|
||||
{
|
||||
type: "custom-Dropdown",
|
||||
label: "API Version",
|
||||
position: "right",
|
||||
items: [
|
||||
{ to: "/api/next/", label: "1.28-dev" },
|
||||
{ to: "/api/", label: "1.27.1" },
|
||||
{ to: "/api/1.26/", label: "1.26.4" },
|
||||
{ to: "/api/1.25/", label: "1.25.5" },
|
||||
{ to: "/api/1.24/", label: "1.24.7" },
|
||||
{ to: "/api/1.23/", label: "1.23.8" },
|
||||
{ to: "/api/1.22/", label: "1.22.6" },
|
||||
],
|
||||
routerRgx: "/api/",
|
||||
classNames: "api-dropdown",
|
||||
},
|
||||
{
|
||||
type: "custom-Dropdown",
|
||||
label: "Runner Version",
|
||||
position: "right",
|
||||
items: [
|
||||
{ to: "/runner/develop/", label: "develop" },
|
||||
...runnerVersions.map((version) => ({
|
||||
to: runnerVersionPath(version),
|
||||
label: runnerVersionLabel(version),
|
||||
})),
|
||||
],
|
||||
routerRgx: "/runner/",
|
||||
classNames: "runner-dropdown",
|
||||
},
|
||||
{
|
||||
to: "help/support",
|
||||
position: "right",
|
||||
label: "Support",
|
||||
activeBaseRegex: "help/support",
|
||||
},
|
||||
{
|
||||
href: "https://gitea.com/user/login",
|
||||
label: "Sign In",
|
||||
position: "right",
|
||||
className: "internal-href signin-button",
|
||||
target: "_self",
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: "dark",
|
||||
links: [
|
||||
{
|
||||
title: "Community",
|
||||
items: [
|
||||
{
|
||||
label: "Awesome Gitea",
|
||||
href: "https://gitea.com/gitea/awesome-gitea",
|
||||
},
|
||||
{
|
||||
label: "Stack Overflow",
|
||||
href: "https://stackoverflow.com/questions/tagged/gitea",
|
||||
},
|
||||
{
|
||||
label: "Discord",
|
||||
href: "https://discord.gg/gitea",
|
||||
},
|
||||
{
|
||||
label: "Forum",
|
||||
href: "https://forum.gitea.com/",
|
||||
},
|
||||
{
|
||||
label: "Twitter",
|
||||
href: "https://twitter.com/giteaio",
|
||||
},
|
||||
{
|
||||
label: "Mastodon",
|
||||
href: "https://social.gitea.io/@gitea",
|
||||
},
|
||||
{
|
||||
label: "Bluesky",
|
||||
href: "https://bsky.app/profile/gitea.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Code",
|
||||
items: [
|
||||
{
|
||||
label: "GitHub",
|
||||
href: "https://github.com/go-gitea/gitea",
|
||||
},
|
||||
{
|
||||
label: "Gitea",
|
||||
href: "https://gitea.com/gitea",
|
||||
},
|
||||
{
|
||||
label: "Tea CLI",
|
||||
href: "https://gitea.com/gitea/tea",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
prism: {
|
||||
theme: lightCodeTheme,
|
||||
darkTheme: darkCodeTheme,
|
||||
additionalLanguages: ["ini", "diff", "json", "http", "docker", "php"],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -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
|
||||
---
|
||||
|
||||
# 注册为 Windows 服务
|
||||
|
||||
## 准备工作
|
||||
|
||||
在 C:\gitea\custom\conf\app.ini 中进行了以下更改:
|
||||
|
||||
@@ -6,7 +6,7 @@ sidebar_position: 25
|
||||
|
||||
---
|
||||
|
||||
## 变量
|
||||
# 变量
|
||||
|
||||
您可以创建用户、组织和仓库级别的变量。变量的级别取决于创建它的位置。当创建变量时,变量的名称会被
|
||||
转换为大写,在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)
|
||||
|
||||
+1
-1
@@ -57,4 +57,4 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
|
||||
|
||||
## 合并请求模板
|
||||
|
||||
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](usage/issue-pull-request-templates.md)
|
||||
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](issue-pull-request-templates.md)
|
||||
|
||||
+1
-1
@@ -57,4 +57,4 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
|
||||
|
||||
## 合并请求模板
|
||||
|
||||
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](usage/issue-pull-request-templates.md)
|
||||
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](issue-pull-request-templates.md)
|
||||
|
||||
+1
-1
@@ -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": "博客"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"outdated.message": {
|
||||
"message": "當前中文文檔翻譯不是最新版,請訪問英文版本查看最新內容,或"
|
||||
},
|
||||
"outdated.help": {
|
||||
"message": "幫助我們翻譯"
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ aliases:
|
||||
- /zh-tw/windows-service
|
||||
---
|
||||
|
||||
# 註冊為 Windows 服務
|
||||
|
||||
## 準備工作
|
||||
|
||||
在 C:\gitea\custom\conf\app.ini 中進行了以下更改:
|
||||
|
||||
@@ -6,7 +6,7 @@ sidebar_position: 25
|
||||
|
||||
---
|
||||
|
||||
## 變量
|
||||
# 變量
|
||||
|
||||
您可以建立使用者、組織和儲存庫級別的變量。變量的級別取決於建立它的位置。當建立變量時,變量的名稱會被
|
||||
轉換為大寫,在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
@@ -3,53 +3,15 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"start-CSRApi": "cross-env API_SSR='false' docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"build-CSRApi": "cross-env API_SSR='false' docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"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"
|
||||
]
|
||||
"dev": "pnpm --filter @gitea-docs/site dev",
|
||||
"dev:en-latest": "pnpm --filter @gitea-docs/site dev:en-latest",
|
||||
"build": "pnpm --filter @gitea-docs/site build",
|
||||
"preview": "pnpm --filter @gitea-docs/site preview",
|
||||
"check": "pnpm --filter @gitea-docs/site check",
|
||||
"cut-version": "node scripts/cut-version.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"packageManager": "[email protected]"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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('/');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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('/');
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
Generated
+3136
-11720
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -1,8 +1,12 @@
|
||||
packages:
|
||||
- sites/*
|
||||
- packages/*
|
||||
|
||||
savePrefix: ''
|
||||
dedupePeerDependents: false
|
||||
updateNotifier: false
|
||||
minimumReleaseAge: 0
|
||||
|
||||
allowBuilds:
|
||||
'@swc/core': true
|
||||
core-js: false
|
||||
esbuild: true
|
||||
sharp: true
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Descriptions of the operation tags of the gitea api.
|
||||
*
|
||||
* The swagger documents gitea generates carry no top level `tags` section, so
|
||||
* starlight-openapi treats every tag as "minimal": it builds no landing page
|
||||
* for it and the api overview can only list all operations at once. Adding the
|
||||
* descriptions here gives every tag a page of its own, which is what the
|
||||
* overview links to.
|
||||
*
|
||||
* Each line summarizes what the operations of that tag actually cover; edit
|
||||
* them here, they are the same for every documented version.
|
||||
*/
|
||||
export const apiTagDescriptions: Record<string, string> = {
|
||||
admin: 'Instance administration: users, organizations, runners, cron jobs and system hooks.',
|
||||
issue: 'Issues and pull request conversations: comments, labels, milestones, reactions and timelines.',
|
||||
miscellaneous: 'Markdown rendering, signing keys, server version and instance metadata.',
|
||||
notification: 'Notification threads and subscriptions of the authenticated user.',
|
||||
organization: 'Organizations and their teams, members, repositories and settings.',
|
||||
package: 'Package registries: listing, inspecting and deleting published packages.',
|
||||
repository:
|
||||
'Repositories and their contents, branches, tags, releases, pull requests, webhooks and actions.',
|
||||
settings: 'Instance settings exposed to clients: api, attachment, repository and ui limits.',
|
||||
user: 'The authenticated user and other users: keys, tokens, follows, stars and applications.',
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DocSearchClientOptions } from '@astrojs/starlight-docsearch';
|
||||
|
||||
/**
|
||||
* Algolia docsearch, scoped to what is being read. Every page carries the
|
||||
* `docsearch:product`, `docsearch:version` and `docsearch:language` meta tags
|
||||
* (see `src/lib/search.ts`), the crawler turns them into facets and the modal
|
||||
* filters on them, so searching the 1.26 chinese docs never returns a 1.22
|
||||
* english page.
|
||||
*
|
||||
* The credentials are the public, search only ones and are injected at build
|
||||
* time from `PUBLIC_DOCSEARCH_*`.
|
||||
*/
|
||||
function facet(name: string): string | undefined {
|
||||
return document.querySelector<HTMLMetaElement>(`meta[name="docsearch:${name}"]`)?.content;
|
||||
}
|
||||
|
||||
export default {
|
||||
appId: import.meta.env.PUBLIC_DOCSEARCH_APP_ID,
|
||||
apiKey: import.meta.env.PUBLIC_DOCSEARCH_API_KEY,
|
||||
indexName: import.meta.env.PUBLIC_DOCSEARCH_INDEX_NAME ?? 'gitea',
|
||||
searchParameters: {
|
||||
facetFilters: [
|
||||
...(facet('language') ? [`language:${facet('language')}`] : []),
|
||||
...(facet('product') ? [`product:${facet('product')}`] : []),
|
||||
...(facet('version') ? [`version:${facet('version')}`] : []),
|
||||
],
|
||||
},
|
||||
} satisfies DocSearchClientOptions;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Repository root, the directory holding `docs/`, `versioned_docs/` and the
|
||||
* other content trees. Looked up from the working directory instead of
|
||||
* `import.meta.url`, which points into the bundle once astro has built the
|
||||
* server chunks.
|
||||
*/
|
||||
function findRepoRoot(start: string): string {
|
||||
let current = path.resolve(start);
|
||||
for (let depth = 0; depth < 6; depth += 1) {
|
||||
if (existsSync(path.join(current, 'versions.json'))) return current;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
throw new Error(`unable to locate the repository root from ${start}`);
|
||||
}
|
||||
|
||||
export const repoRoot = findRepoRoot(process.cwd());
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Chrome of the site: announcement strip, footer columns and analytics. */
|
||||
|
||||
export const announcement = {
|
||||
/** Bump to show the strip again to everyone who dismissed the previous one. */
|
||||
id: 'gitea-cloud-1',
|
||||
href: 'https://about.gitea.com/products/cloud',
|
||||
text: 'Try Gitea Cloud ☁️ for 30 days → Accelerate your Development & Deploys!',
|
||||
};
|
||||
|
||||
/** Key the dismissed announcement is remembered under. */
|
||||
export const announcementStorageKey = 'gitea:announcement-dismissed';
|
||||
|
||||
export const footerLinks = [
|
||||
{
|
||||
title: 'Community',
|
||||
items: [
|
||||
{ label: 'Awesome Gitea', href: 'https://gitea.com/gitea/awesome-gitea' },
|
||||
{ label: 'Stack Overflow', href: 'https://stackoverflow.com/questions/tagged/gitea' },
|
||||
{ label: 'Discord', href: 'https://discord.gg/gitea' },
|
||||
{ label: 'Forum', href: 'https://forum.gitea.com/' },
|
||||
{ label: 'Twitter', href: 'https://twitter.com/giteaio' },
|
||||
{ label: 'Mastodon', href: 'https://social.gitea.io/@gitea' },
|
||||
{ label: 'Bluesky', href: 'https://bsky.app/profile/gitea.com' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Code',
|
||||
items: [
|
||||
{ label: 'GitHub', href: 'https://github.com/go-gitea/gitea' },
|
||||
{ label: 'Gitea', href: 'https://gitea.com/gitea' },
|
||||
{ label: 'Tea CLI', href: 'https://gitea.com/gitea/tea' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'More',
|
||||
items: [
|
||||
{ label: 'Blog', href: 'https://blog.gitea.com/' },
|
||||
{ label: 'Gitea Cloud', href: 'https://about.gitea.com/products/cloud' },
|
||||
{ label: 'Enterprise', href: 'https://about.gitea.com/products/gitea' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const analytics = {
|
||||
gtagId: 'G-KHM0KYT506',
|
||||
plausibleDomain: 'docs.gitea.com',
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* The few strings the gitea specific components add. Kept here instead of in a
|
||||
* starlight i18n collection because the content collection is filled by the
|
||||
* loader, which has no place for the ui translation files.
|
||||
*/
|
||||
type Strings = {
|
||||
versionLabel: string;
|
||||
languageLabel: string;
|
||||
/** Labels of the products in the header, keyed by product id. */
|
||||
products: Record<string, string>;
|
||||
/** Titles of the footer columns, keyed by the english title. */
|
||||
footer: Record<string, string>;
|
||||
signIn: string;
|
||||
translationNotice: string;
|
||||
translationHelp: string;
|
||||
dismissAnnouncement: string;
|
||||
unreleased: (latest: string) => string;
|
||||
outdated: (version: string, latest: string) => string;
|
||||
};
|
||||
|
||||
const en: Strings = {
|
||||
versionLabel: 'Version',
|
||||
languageLabel: 'Language',
|
||||
products: { docs: 'Docs', api: 'API', runner: 'Runner', enterprise: 'Enterprise' },
|
||||
footer: { Community: 'Community', Code: 'Code', More: 'More' },
|
||||
signIn: 'Sign In',
|
||||
translationNotice: 'This translation may be behind the english original.',
|
||||
translationHelp: 'Help us translate it',
|
||||
dismissAnnouncement: 'Dismiss this announcement',
|
||||
unreleased: (latest) =>
|
||||
`This is the documentation of the next version, still under development. <a href="${latest}">See the latest release</a>.`,
|
||||
outdated: (version, latest) =>
|
||||
`This is the documentation of ${version}, which is no longer the latest release. <a href="${latest}">See the latest release</a>.`,
|
||||
};
|
||||
|
||||
const strings: Record<string, Strings> = {
|
||||
'en-us': en,
|
||||
'zh-cn': {
|
||||
versionLabel: '版本',
|
||||
languageLabel: '语言',
|
||||
products: { docs: '文档', api: 'API', runner: 'Runner', enterprise: '企业版' },
|
||||
footer: { Community: '社区', Code: '开源代码', More: '更多' },
|
||||
signIn: '登录',
|
||||
translationNotice: '当前中文文档翻译不是最新版,访问英文版本查看最新内容,或',
|
||||
translationHelp: '帮助我们翻译',
|
||||
dismissAnnouncement: '关闭此提示',
|
||||
unreleased: (latest) =>
|
||||
`这是下一个版本的文档,仍在开发中。<a href="${latest}">查看最新发布版本</a>。`,
|
||||
outdated: (version, latest) =>
|
||||
`这是 ${version} 的文档,已不是最新发布版本。<a href="${latest}">查看最新发布版本</a>。`,
|
||||
},
|
||||
'zh-tw': {
|
||||
versionLabel: '版本',
|
||||
languageLabel: '語言',
|
||||
products: { docs: '文件', api: 'API', runner: 'Runner', enterprise: '企業版' },
|
||||
footer: { Community: '社區', Code: '開源程式碼', More: '更多' },
|
||||
signIn: '登入',
|
||||
translationNotice: '當前中文文檔翻譯不是最新版,訪問英文版本查看最新內容,或',
|
||||
translationHelp: '幫助我們翻譯',
|
||||
dismissAnnouncement: '關閉此提示',
|
||||
unreleased: (latest) =>
|
||||
`這是下一個版本的文檔,仍在開發中。<a href="${latest}">查看最新發布版本</a>。`,
|
||||
outdated: (version, latest) =>
|
||||
`這是 ${version} 的文檔,已不是最新發布版本。<a href="${latest}">查看最新發布版本</a>。`,
|
||||
},
|
||||
};
|
||||
|
||||
export function t(locale: string): Strings {
|
||||
return strings[locale] ?? en;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { giteaDocsLoader } from '@gitea-docs/content-loader';
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { repoRoot } from './config/paths';
|
||||
import { giteaDocsSchema } from './schema';
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({
|
||||
loader: giteaDocsLoader({ root: repoRoot }),
|
||||
schema: giteaDocsSchema(),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { StarlightPlugin } from '@astrojs/starlight/types';
|
||||
|
||||
/**
|
||||
* Registers the middleware that adjusts the generated api pages. It has to be a
|
||||
* plugin so it can ask for `order: 'post'`, which puts it after the
|
||||
* starlight-openapi middleware that builds the sidebar groups; list it after
|
||||
* `starlightOpenAPI()` in the plugin array.
|
||||
*/
|
||||
export function giteaApiSidebar(): StarlightPlugin {
|
||||
return {
|
||||
name: 'gitea-api',
|
||||
hooks: {
|
||||
'config:setup': ({ addRouteMiddleware }) => {
|
||||
addRouteMiddleware({ entrypoint: './src/middleware/api.ts', order: 'post' });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { AstroIntegration } from 'astro';
|
||||
import { defaultLocale, locales, products, type LocaleId } from '@gitea-docs/content-loader';
|
||||
|
||||
/**
|
||||
* Two things the generators cannot get right on their own:
|
||||
*
|
||||
* - starlight-openapi slugifies the base path of a schema, so `/api/1.26/` is
|
||||
* generated as `/api/126/`. The directories are renamed and the links inside
|
||||
* the build are rewritten.
|
||||
* - starlight builds a fallback page in every configured language for every
|
||||
* english page. That is what we want for the docs, but the runner and the api
|
||||
* are separate products with their own language list, so `/zh-cn/runner/` has
|
||||
* to go; `cloudflare/_redirects` sends it to the english page.
|
||||
*/
|
||||
export function giteaPostBuild(): AstroIntegration {
|
||||
return {
|
||||
name: 'gitea-post-build',
|
||||
hooks: {
|
||||
'astro:build:done': async ({ dir, logger }) => {
|
||||
const dist = fileURLToPath(dir);
|
||||
const renames = await renameApiVersions(dist);
|
||||
if (renames.length > 0) {
|
||||
await rewriteLinks(dist, renames);
|
||||
logger.info(
|
||||
`renamed the api version directories: ${renames
|
||||
.map(([from, to]) => `${from} -> ${to}`)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const pruned = await pruneForeignLocales(dist);
|
||||
if (pruned.length > 0) logger.info(`removed the fallback pages of ${pruned.join(', ')}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Same slugification github-slugger applies to a starlight-openapi base path. */
|
||||
function slugify(segment: string): string {
|
||||
return segment.toLowerCase().replace(/[^\w-]/g, '');
|
||||
}
|
||||
|
||||
/** `/api/126/` back to `/api/1.26/`, as the version picker and the old urls expect. */
|
||||
async function renameApiVersions(dist: string): Promise<[string, string][]> {
|
||||
const api = products.find((product) => product.id === 'api');
|
||||
if (!api) return [];
|
||||
|
||||
const renames: [string, string][] = [];
|
||||
for (const version of api.versions) {
|
||||
if (!version.path || slugify(version.path) === version.path) continue;
|
||||
try {
|
||||
await rename(
|
||||
path.join(dist, api.base, slugify(version.path)),
|
||||
path.join(dist, api.base, version.path),
|
||||
);
|
||||
renames.push([slugify(version.path), version.path]);
|
||||
} catch {
|
||||
// the version was not part of this build
|
||||
}
|
||||
}
|
||||
return renames;
|
||||
}
|
||||
|
||||
async function rewriteLinks(dist: string, renames: [string, string][]): Promise<void> {
|
||||
const api = products.find((product) => product.id === 'api')!;
|
||||
const replacements = renames.map(
|
||||
([from, to]) => [`/${api.base}/${from}/`, `/${api.base}/${to}/`] as const,
|
||||
);
|
||||
|
||||
for await (const file of walk(dist)) {
|
||||
if (!/\.(html|xml|js|json)$/.test(file)) continue;
|
||||
const contents = await readFile(file, 'utf-8');
|
||||
let updated = contents;
|
||||
for (const [from, to] of replacements) updated = updated.replaceAll(from, to);
|
||||
if (updated !== contents) await writeFile(file, updated);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the language directories of the products not published in them. */
|
||||
async function pruneForeignLocales(dist: string): Promise<string[]> {
|
||||
const removed: string[] = [];
|
||||
for (const product of products) {
|
||||
if (!product.base || product.externalBaseUrl) continue;
|
||||
for (const locale of Object.keys(locales) as LocaleId[]) {
|
||||
if (locale === defaultLocale || product.locales.includes(locale)) continue;
|
||||
try {
|
||||
await rm(path.join(dist, locale, product.base), { recursive: true });
|
||||
removed.push(`/${locale}/${product.base}/`);
|
||||
} catch {
|
||||
// nothing was built there
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
async function* walk(dir: string): AsyncGenerator<string> {
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(full);
|
||||
else yield full;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
defaultLocale,
|
||||
getProduct,
|
||||
routePrefix,
|
||||
type LocaleId,
|
||||
} from '@gitea-docs/content-loader';
|
||||
import { getCollection } from 'astro:content';
|
||||
import type { GiteaMeta } from '../schema';
|
||||
|
||||
const entries = await getCollection('docs');
|
||||
|
||||
/** Every route the site serves, used to keep the pickers from linking to a 404. */
|
||||
const hrefs = new Set(
|
||||
entries.map((entry) => {
|
||||
const id = entry.id;
|
||||
if (id === 'index' || id === '') return '/';
|
||||
return `/${id.endsWith('/index') ? id.slice(0, -'/index'.length) : id}/`;
|
||||
}),
|
||||
);
|
||||
|
||||
export function exists(href: string): boolean {
|
||||
return hrefs.has(href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same page in another version or language. Falls back to the root of the
|
||||
* target when the page does not exist there, which happens for a version that
|
||||
* did not document the feature yet or for a page a translation has moved.
|
||||
*/
|
||||
export function switchHref(
|
||||
pathname: string,
|
||||
meta: GiteaMeta,
|
||||
target: { version?: string; locale?: LocaleId },
|
||||
): string {
|
||||
const product = getProduct(meta.product);
|
||||
const version = product.versions.find(
|
||||
(candidate) => candidate.id === (target.version ?? meta.version),
|
||||
);
|
||||
if (!version) return '/';
|
||||
|
||||
const locale = target.locale ?? (meta.locale as LocaleId);
|
||||
const prefix = routePrefix(product, version, locale);
|
||||
const root = prefix ? `/${prefix}/` : '/';
|
||||
|
||||
const current = meta.prefix ? `/${meta.prefix}/` : '/';
|
||||
if (!pathname.startsWith(current)) return root;
|
||||
const rest = pathname.slice(current.length);
|
||||
const href = `${root}${rest}`;
|
||||
return exists(href) ? href : root;
|
||||
}
|
||||
|
||||
export { defaultLocale };
|
||||
@@ -0,0 +1,21 @@
|
||||
import { locales, type LocaleId } from '@gitea-docs/content-loader';
|
||||
import type { GiteaMeta } from '../schema';
|
||||
|
||||
/**
|
||||
* Search facets of a page. The docsearch crawler turns these into the
|
||||
* `product`, `version` and `language` facets the search modal filters on, so
|
||||
* a search stays inside the product, version and language being read.
|
||||
*/
|
||||
export function searchMetaTags(meta: GiteaMeta): {
|
||||
tag: 'meta';
|
||||
attrs: { name: string; content: string };
|
||||
}[] {
|
||||
return [
|
||||
{ tag: 'meta', attrs: { name: 'docsearch:product', content: meta.product } },
|
||||
{ tag: 'meta', attrs: { name: 'docsearch:version', content: meta.version } },
|
||||
{
|
||||
tag: 'meta',
|
||||
attrs: { name: 'docsearch:language', content: locales[meta.locale as LocaleId]?.lang ?? meta.locale },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildSidebars,
|
||||
products,
|
||||
type DocusaurusSidebarItem,
|
||||
type SidebarEntry,
|
||||
type SidebarPage,
|
||||
} from '@gitea-docs/content-loader';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { repoRoot } from '../config/paths';
|
||||
|
||||
/**
|
||||
* Sidebar files of the docusaurus site: the top level order of the docs and the
|
||||
* handwritten sidebars of the runner, one per version. Read as is, so cutting a
|
||||
* version keeps needing no change here.
|
||||
*/
|
||||
function docusaurusSidebars(): Map<string, DocusaurusSidebarItem[]> {
|
||||
const sidebars = new Map<string, DocusaurusSidebarItem[]>();
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const read = (file: string): Record<string, DocusaurusSidebarItem[]> | undefined => {
|
||||
const full = path.join(repoRoot, file);
|
||||
if (!existsSync(full)) return undefined;
|
||||
try {
|
||||
return file.endsWith('.json')
|
||||
? (JSON.parse(readFileSync(full, 'utf-8')) as Record<string, DocusaurusSidebarItem[]>)
|
||||
: (require(full) as Record<string, DocusaurusSidebarItem[]>);
|
||||
} catch (error) {
|
||||
// an unreadable sidebar would silently fall back to the directory tree,
|
||||
// which reorders the whole navigation without any other sign
|
||||
throw new Error(`unable to read the sidebar ${file}: ${(error as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const files: Record<string, string> = {};
|
||||
for (const product of products) {
|
||||
for (const version of product.versions) {
|
||||
if (product.id === 'docs') {
|
||||
files[`docs@${version.id}`] =
|
||||
version.id === 'next'
|
||||
? 'sidebars.js'
|
||||
: `versioned_sidebars/version-${version.id}-sidebars.json`;
|
||||
} else if (product.id === 'runner') {
|
||||
files[`runner@${version.id}`] =
|
||||
version.id === 'develop'
|
||||
? 'runner-sidebars.js'
|
||||
: `runner-docs_versioned_sidebars/version-${version.id}-sidebars.json`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, file] of Object.entries(files)) {
|
||||
const config = read(file);
|
||||
const items = config && (config.docs ?? config.runner ?? Object.values(config)[0]);
|
||||
if (items) sidebars.set(key, items);
|
||||
}
|
||||
return sidebars;
|
||||
}
|
||||
|
||||
const entries = await getCollection('docs');
|
||||
|
||||
const pages: SidebarPage[] = entries.flatMap((entry) => {
|
||||
const meta = entry.data.gitea;
|
||||
if (!meta) return [];
|
||||
const id = entry.id;
|
||||
const href =
|
||||
id === 'index' || id === ''
|
||||
? '/'
|
||||
: `/${id.endsWith('/index') ? id.slice(0, -'/index'.length) : id}/`;
|
||||
return [{ id, href, title: entry.data.title, hidden: entry.data.sidebar.hidden, meta }];
|
||||
});
|
||||
|
||||
/** Sidebars of every (product, version, language), keyed by their route prefix. */
|
||||
export const sidebars: Map<string, SidebarEntry[]> = buildSidebars(pages, docusaurusSidebars());
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
defaultLocale,
|
||||
getProduct,
|
||||
locales,
|
||||
products,
|
||||
routePrefix,
|
||||
type LocaleId,
|
||||
type ProductDef,
|
||||
type VersionDef,
|
||||
} from '@gitea-docs/content-loader';
|
||||
import type { GiteaMeta } from '../schema';
|
||||
import { t } from '../config/strings';
|
||||
|
||||
export interface PickerItem {
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
/** Url of the root of a (product, version, language). */
|
||||
export function versionHref(product: ProductDef, version: VersionDef, locale: LocaleId): string {
|
||||
if (product.externalBaseUrl) return product.externalBaseUrl;
|
||||
const prefix = routePrefix(product, version, locale);
|
||||
return prefix ? `/${prefix}/` : '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Version picker of the current page. A version is entered at the page with the
|
||||
* same route when it exists, at the root of the version otherwise; the check is
|
||||
* done client side because the target may live in another build chunk.
|
||||
*/
|
||||
export function versionPicker(meta: GiteaMeta): PickerItem[] {
|
||||
const product = getProduct(meta.product);
|
||||
return product.versions.map((version) => ({
|
||||
id: version.id,
|
||||
label: version.label,
|
||||
href: versionHref(product, version, meta.locale as LocaleId),
|
||||
current: version.id === meta.version,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Languages the product of the page is published in. */
|
||||
export function languagePicker(meta: GiteaMeta): PickerItem[] {
|
||||
const product = getProduct(meta.product);
|
||||
const version = product.versions.find((candidate) => candidate.id === meta.version);
|
||||
if (!version) return [];
|
||||
return product.locales.map((locale) => ({
|
||||
id: locale,
|
||||
label: locales[locale].label,
|
||||
href: versionHref(product, version, locale),
|
||||
current: locale === meta.locale,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Top level navigation, one entry per product. */
|
||||
export function productNav(meta: GiteaMeta | undefined): PickerItem[] {
|
||||
return products.map((product) => {
|
||||
const locale = (meta?.locale as LocaleId | undefined) ?? defaultLocale;
|
||||
const supported = product.locales.includes(locale) ? locale : defaultLocale;
|
||||
const version =
|
||||
product.versions.find((candidate) => candidate.latest) ?? product.versions[0];
|
||||
return {
|
||||
id: product.id,
|
||||
label: product.label,
|
||||
href: version ? versionHref(product, version, supported) : product.externalBaseUrl ?? '/',
|
||||
current: product.id === meta?.product,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Banner shown on every page of a development or an outdated version. */
|
||||
export function versionBanner(meta: GiteaMeta): string | undefined {
|
||||
const product = getProduct(meta.product);
|
||||
const version = product.versions.find((candidate) => candidate.id === meta.version);
|
||||
if (!version) return undefined;
|
||||
const latest = product.versions.find((candidate) => candidate.latest);
|
||||
const strings = t(meta.locale);
|
||||
const latestHref = latest ? versionHref(product, latest, meta.locale as LocaleId) : '/';
|
||||
if (version.banner === 'unreleased') return strings.unreleased(latestHref);
|
||||
if (latest && !version.latest && version.id !== 'develop') {
|
||||
return strings.outdated(version.label, latestHref);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { defineRouteMiddleware } from '@astrojs/starlight/route-data';
|
||||
import { getProduct } from '@gitea-docs/content-loader';
|
||||
|
||||
/**
|
||||
* Adjusts the pages starlight-openapi generates.
|
||||
*
|
||||
* The plugin replaces a placeholder group in the sidebar with one group per
|
||||
* schema, so an api page would list all seven documented versions. Keep the one
|
||||
* of the version being read and lift its entries to the top level: the version
|
||||
* is already picked with the version selector. It also titles both the schema
|
||||
* overview and every tag page "Overview", which makes the browser tab, the
|
||||
* breadcrumbs and the search results ambiguous.
|
||||
*
|
||||
* This runs after the plugin, which registers its own middleware with
|
||||
* `order: 'post'`; `src/routeData.ts` runs before it and must not touch the
|
||||
* placeholder, or there would be nothing left for the plugin to replace.
|
||||
*/
|
||||
export const onRequest = defineRouteMiddleware((context) => {
|
||||
const route = context.locals.starlightRoute;
|
||||
const meta = route.entry.data.gitea;
|
||||
if (!meta || meta.product !== 'api') return;
|
||||
|
||||
const version = getProduct('api').versions.find((candidate) => candidate.id === meta.version);
|
||||
if (!version) return;
|
||||
|
||||
const group = route.sidebar.find(
|
||||
(entry) => entry.type === 'group' && entry.label === `API ${version.label}`,
|
||||
);
|
||||
if (group && group.type === 'group') route.sidebar = group.entries;
|
||||
|
||||
const tag = route.id.match(/\/operations\/tags\/([^/]+)$/)?.[1];
|
||||
const title = tag
|
||||
? tag.charAt(0).toUpperCase() + tag.slice(1)
|
||||
: route.id.includes('/operations/')
|
||||
? undefined
|
||||
: `Gitea API ${version.label}`;
|
||||
|
||||
if (title) {
|
||||
const previous = route.entry.data.title;
|
||||
route.entry.data.title = title;
|
||||
// the head tags are built before the route middleware runs, so the document
|
||||
// title has to be replaced as well
|
||||
for (const entry of route.head) {
|
||||
if (entry.tag === 'title' && entry.content?.startsWith(previous)) {
|
||||
entry.content = entry.content.replace(previous, title);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { defineRouteMiddleware } from '@astrojs/starlight/route-data';
|
||||
import { flattenSidebar, markCurrent, metaFromRouteId } from '@gitea-docs/content-loader';
|
||||
import { sidebars } from './lib/sidebars';
|
||||
import { searchMetaTags } from './lib/search';
|
||||
import { versionBanner } from './lib/versions';
|
||||
|
||||
/**
|
||||
* Starlight has a single global sidebar, the gitea docs need one per product,
|
||||
* version and language. The sidebars are built from the loaded pages and the
|
||||
* matching one is selected here, together with the version banner and the
|
||||
* search facets of the page.
|
||||
*/
|
||||
export const onRequest = defineRouteMiddleware((context) => {
|
||||
const route = context.locals.starlightRoute;
|
||||
// the 404 page belongs to no product: the docs are served from the root, so
|
||||
// resolving it by route would place it in the docs and show their version and
|
||||
// language pickers on a page that is not a documentation page
|
||||
if (route.id === '404') return;
|
||||
|
||||
// pages built by starlight-openapi carry no loader metadata, they are placed
|
||||
// in the matrix by their route instead
|
||||
const routeMeta = metaFromRouteId(route.id);
|
||||
const meta =
|
||||
route.entry.data.gitea ?? (routeMeta && { ...routeMeta, dir: '', name: '' });
|
||||
if (!meta) return;
|
||||
// components read the metadata off the entry, so make the resolved one visible
|
||||
route.entry.data.gitea = meta;
|
||||
|
||||
// the api sidebar is built by starlight-openapi after this middleware and
|
||||
// narrowed down in src/middleware/apiSidebar.ts
|
||||
const entries = sidebars.get(meta.prefix);
|
||||
if (entries) {
|
||||
const sidebar = markCurrent(entries, context.url.pathname);
|
||||
route.sidebar = sidebar;
|
||||
|
||||
// pagination is derived from the sidebar, so it has to be recomputed
|
||||
const links = flattenSidebar(sidebar);
|
||||
const index = links.findIndex((link) => link.isCurrent);
|
||||
route.pagination = {
|
||||
prev: index > 0 ? links[index - 1] : undefined,
|
||||
next: index >= 0 && index < links.length - 1 ? links[index + 1] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const banner = versionBanner(meta);
|
||||
if (banner && !route.entry.data.banner) route.entry.data.banner = { content: banner };
|
||||
|
||||
route.head.push(...searchMetaTags(meta));
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { docsSchema } from '@astrojs/starlight/schema';
|
||||
import { z } from 'astro:content';
|
||||
|
||||
/**
|
||||
* Metadata the gitea loader attaches to every page: which part of the product
|
||||
* matrix it belongs to and where it sits in the source tree. Used to build the
|
||||
* per version sidebars, the version and language pickers and the search facets.
|
||||
*/
|
||||
export const giteaMeta = z.object({
|
||||
product: z.enum(['docs', 'api', 'runner', 'enterprise']),
|
||||
version: z.string(),
|
||||
locale: z.string(),
|
||||
prefix: z.string(),
|
||||
/** Directory of the source file, relative to the version directory. */
|
||||
dir: z.string().default(''),
|
||||
/** File name without extension, or directory name for a category page. */
|
||||
name: z.string().default(''),
|
||||
order: z.number().optional(),
|
||||
/** Set on the generated index page of a category. */
|
||||
category: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type GiteaMeta = typeof giteaMeta._output;
|
||||
|
||||
export const giteaDocsSchema = () => docsSchema({ extend: z.object({ gitea: giteaMeta.optional() }) });
|
||||
@@ -0,0 +1,155 @@
|
||||
/* Layout: starlight centres a 45rem column, which leaves a wide gap on the left
|
||||
of the content and gives the table of contents the rest of the space. The
|
||||
docs are reference material with wide tables and code blocks, so the content
|
||||
column is widened and the "On this page" column is pinned to a fixed width
|
||||
instead of growing with the viewport. */
|
||||
:root {
|
||||
--sl-content-width: 52rem;
|
||||
--gitea-toc-width: 22.5rem;
|
||||
/* The header bar stacks the announcement strip on top of the navigation row.
|
||||
Starlight offsets the sidebar, the content and the table of contents by
|
||||
`--sl-nav-height`, so it has to cover both. The navigation row is as tall
|
||||
as the one of docs.gitea.com. */
|
||||
--gitea-announcement-height: 2rem;
|
||||
--gitea-nav-row-height: 3.75rem;
|
||||
--sl-nav-height: calc(var(--gitea-announcement-height) + var(--gitea-nav-row-height));
|
||||
}
|
||||
|
||||
@media (min-width: 72rem) {
|
||||
.right-sidebar-container {
|
||||
width: var(--gitea-toc-width);
|
||||
}
|
||||
|
||||
[data-has-sidebar][data-has-toc] .main-pane {
|
||||
width: calc(100% - var(--gitea-toc-width));
|
||||
/* centre the content in the space left between the two sidebars */
|
||||
--sl-content-margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* A dismissed announcement collapses the strip, which shrinks the header bar
|
||||
and every offset derived from it. */
|
||||
:root[data-gitea-announcement='dismissed'] {
|
||||
--gitea-announcement-height: 0rem;
|
||||
}
|
||||
|
||||
[data-gitea-announcement='dismissed'] .gitea-announcement {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The header bar is laid out by src/components/Header.astro: the strip has to
|
||||
reach the edges and the navigation row provides its own padding. */
|
||||
.page > .header {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Starlight sizes the logo off the navigation height, which here also covers
|
||||
the announcement strip. Pin it to the size docs.gitea.com uses. */
|
||||
.site-title img {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
/* Centre the mobile menu button on the navigation row instead of on the whole
|
||||
header bar, which the announcement strip made taller. */
|
||||
starlight-menu-button button {
|
||||
top: calc(
|
||||
var(--gitea-announcement-height) + (var(--gitea-nav-row-height) - var(--sl-menu-button-size)) / 2
|
||||
);
|
||||
}
|
||||
|
||||
/* Starlight gives the search box the free middle column of the header grid and
|
||||
left aligns it there. Push it to the right, next to the pickers, the theme
|
||||
switch and the sign in button. */
|
||||
@media (min-width: 50rem) {
|
||||
.header > :nth-child(2) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* algolia docsearch brings its own button, which grows to fill the column */
|
||||
.header .DocSearch-Button {
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* The header keeps the product navigation on the left and the pickers, the
|
||||
theme switch and the sign in button on the right. */
|
||||
.gitea-sign-in {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--sl-color-accent);
|
||||
color: var(--sl-color-text-invert);
|
||||
font-size: var(--sl-text-sm);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gitea-sign-in:hover {
|
||||
background-color: var(--sl-color-accent-high);
|
||||
}
|
||||
|
||||
/* Http method badges in the api sidebar. starlight-openapi tags every badge
|
||||
with a `sl-openapi-method-<method>` class; recolour them with the palette of
|
||||
the theme so they follow the light and dark colours, and move them in front
|
||||
of the operation name.
|
||||
|
||||
Every sidebar link is a flex row rather than only the ones holding a badge:
|
||||
a link without one looks the same, and `:has()` would silently drop the whole
|
||||
rule on older browsers, leaving the badge behind the operation name. */
|
||||
.sidebar-content a {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.sidebar-content .sl-badge {
|
||||
order: -1;
|
||||
flex: none;
|
||||
min-width: 3.5em;
|
||||
text-align: center;
|
||||
font-size: var(--sl-text-2xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
font-family: var(--sl-font-mono);
|
||||
padding-inline: 0.3rem;
|
||||
}
|
||||
|
||||
.sl-openapi-method-get,
|
||||
.sl-openapi-method-post,
|
||||
.sl-openapi-method-put,
|
||||
.sl-openapi-method-patch,
|
||||
.sl-openapi-method-delete,
|
||||
.sl-openapi-method-head,
|
||||
.sl-openapi-method-options,
|
||||
.sl-openapi-method-trace {
|
||||
--sl-color-border-badge: transparent;
|
||||
--sl-color-text-badge: var(--gitea-method);
|
||||
--sl-color-bg-badge: color-mix(in srgb, var(--gitea-method) calc(var(--gitea-method-tint) * 100%), transparent);
|
||||
}
|
||||
|
||||
.sl-openapi-method-get {
|
||||
--gitea-method: var(--gitea-method-get);
|
||||
}
|
||||
|
||||
.sl-openapi-method-post {
|
||||
--gitea-method: var(--gitea-method-post);
|
||||
}
|
||||
|
||||
.sl-openapi-method-put {
|
||||
--gitea-method: var(--gitea-method-put);
|
||||
}
|
||||
|
||||
.sl-openapi-method-patch {
|
||||
--gitea-method: var(--gitea-method-patch);
|
||||
}
|
||||
|
||||
.sl-openapi-method-delete {
|
||||
--gitea-method: var(--gitea-method-delete);
|
||||
}
|
||||
|
||||
.sl-openapi-method-head,
|
||||
.sl-openapi-method-options,
|
||||
.sl-openapi-method-trace {
|
||||
--gitea-method: var(--gitea-method-other);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/* Colours of https://about.gitea.com, mapped onto the starlight variables so
|
||||
the documentation matches the rest of the site. The token names in the
|
||||
comments are the ones the about site uses.
|
||||
|
||||
Starlight inverts the meaning of its grey ramp between the two themes:
|
||||
`--sl-color-white` is the strongest text colour and `--sl-color-black` the
|
||||
page background, in both themes. */
|
||||
|
||||
:root {
|
||||
--sl-font:
|
||||
'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, 'Helvetica Neue',
|
||||
Arial, sans-serif;
|
||||
--sl-font-mono:
|
||||
'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Dark, the starlight default theme */
|
||||
:root,
|
||||
::backdrop {
|
||||
--sl-color-white: #e9ebee;
|
||||
--sl-color-gray-1: #d2d4d8; /* app-text */
|
||||
--sl-color-gray-2: #c0c2c7; /* app-text-muted */
|
||||
--sl-color-gray-3: #969aa1; /* app-text-soft */
|
||||
--sl-color-gray-4: #6b6f76;
|
||||
--sl-color-gray-5: #46494f; /* app-border-strong */
|
||||
--sl-color-gray-6: #2c2e32; /* app-surface-subtle */
|
||||
--sl-color-black: #1e1f20; /* app-bg */
|
||||
|
||||
--sl-color-accent-low: #253341; /* app-accent over app-bg */
|
||||
--sl-color-accent: #4183c4; /* app-accent */
|
||||
--sl-color-accent-high: #6ba3d6;
|
||||
--gitea-brand: #4183c4; /* app-accent */
|
||||
|
||||
--sl-color-text: var(--sl-color-gray-1);
|
||||
--sl-color-text-accent: var(--sl-color-accent-high);
|
||||
--sl-color-text-invert: #ffffff;
|
||||
--sl-color-bg: var(--sl-color-black);
|
||||
--sl-color-bg-nav: #2c2e32; /* app-chrome */
|
||||
--sl-color-bg-sidebar: #1a1b1e; /* app-surface-muted */
|
||||
--sl-color-bg-inline-code: #2c2e32;
|
||||
--sl-color-bg-accent: var(--sl-color-accent);
|
||||
--sl-color-hairline-light: #46494f;
|
||||
--sl-color-hairline: #3f4248; /* app-border */
|
||||
--sl-color-hairline-shade: #161718; /* app-surface */
|
||||
--sl-color-backdrop-overlay: rgba(0, 0, 0, 0.72); /* app-overlay */
|
||||
}
|
||||
|
||||
:root[data-theme='light'],
|
||||
[data-theme='light'] ::backdrop {
|
||||
--sl-color-white: #181c21; /* app-text */
|
||||
--sl-color-gray-1: #22262b;
|
||||
--sl-color-gray-2: #40474d; /* app-text-muted */
|
||||
--sl-color-gray-3: #5b6167; /* app-text-soft */
|
||||
--sl-color-gray-4: #8b9198;
|
||||
--sl-color-gray-5: #b9c0c7; /* app-border-strong */
|
||||
--sl-color-gray-6: #d0d7de; /* app-border */
|
||||
--sl-color-gray-7: #f6f7fa; /* app-surface-muted */
|
||||
--sl-color-black: #ffffff; /* app-bg */
|
||||
|
||||
--sl-color-accent-low: #ebf2f9; /* app-surface-subtle */
|
||||
/* the brand blue is 3.99:1 on white, one notch below the contrast needed for
|
||||
body text, so the hover shade of the about site is used for text and for
|
||||
white on blue chips, and the brand blue itself for fills and borders */
|
||||
--sl-color-accent: #3876b3; /* app-link-hover */
|
||||
--sl-color-accent-high: #2b5d8d;
|
||||
--gitea-brand: #4183c4; /* app-accent */
|
||||
|
||||
--sl-color-text: var(--sl-color-white);
|
||||
--sl-color-text-accent: var(--sl-color-accent);
|
||||
--sl-color-text-invert: #ffffff;
|
||||
--sl-color-bg-nav: #f6f7fa; /* app-chrome */
|
||||
--sl-color-bg-sidebar: #f6f7fa;
|
||||
--sl-color-bg-inline-code: #ebf2f9;
|
||||
--sl-color-bg-accent: var(--sl-color-accent);
|
||||
--sl-color-hairline-light: #e3e7eb;
|
||||
--sl-color-hairline: #d0d7de; /* app-border */
|
||||
--sl-color-hairline-shade: #d0d7de;
|
||||
--sl-color-backdrop-overlay: rgba(0, 0, 23, 0.45); /* app-overlay */
|
||||
}
|
||||
|
||||
/* Http method colours of the api sidebar. Muted rather than saturated: there
|
||||
are a few hundred of these chips on a page, and the starlight palette hues
|
||||
are loud enough to turn the sidebar into a rainbow. Tinted background, no
|
||||
border, text carrying the colour. */
|
||||
:root {
|
||||
--gitea-method-get: #56d364;
|
||||
--gitea-method-post: #79b8e8;
|
||||
--gitea-method-put: #b392f0;
|
||||
--gitea-method-patch: #f0883e;
|
||||
--gitea-method-delete: #ff7b72;
|
||||
--gitea-method-other: var(--sl-color-gray-3);
|
||||
--gitea-method-tint: 0.15;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--gitea-method-get: #166534;
|
||||
--gitea-method-post: #2a5f92;
|
||||
--gitea-method-put: #5b32a8;
|
||||
--gitea-method-patch: #9a3d00;
|
||||
--gitea-method-delete: #b3181f;
|
||||
--gitea-method-other: var(--sl-color-gray-3);
|
||||
--gitea-method-tint: 0.1;
|
||||
}
|
||||
|
||||
/* Links inside the content follow the about site: accent, darker on hover. */
|
||||
.sl-markdown-content a:not(:where(.not-content *)) {
|
||||
color: var(--sl-color-text-accent);
|
||||
}
|
||||
|
||||
.sl-markdown-content a:not(:where(.not-content *)):hover {
|
||||
color: var(--sl-color-accent-high);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*", "../../packages/content-loader/src/**/*"],
|
||||
"exclude": ["dist", ".cache"]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import style from "./styles.module.css";
|
||||
|
||||
// skin?: "default" | "primary"
|
||||
export const ActionCard = ({ skin = "default", icon, title, description, svgBackgroundColor, children, className }) => {
|
||||
const styles = { background: svgBackgroundColor};
|
||||
return (
|
||||
<div
|
||||
className={clsx(style.root, className, {
|
||||
[style.skinPrimary]: skin === "primary",
|
||||
})}
|
||||
>
|
||||
<div className={style.icon} style={styles}>{icon}</div>
|
||||
<h3 className={style.title}>{title}</h3>
|
||||
<p className={style.description}>{description}</p>
|
||||
<div className={style.content}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionCard
|
||||
@@ -1,53 +0,0 @@
|
||||
.root {
|
||||
padding: 2rem;
|
||||
border-radius: 16px;
|
||||
background: var(--theme-attention-card-bg-color);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: var(--font-size-big-1);
|
||||
font-weight: var(--ifm-font-weight-bold);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--font-size-large);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 880px) {
|
||||
.root {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.skinPrimary {
|
||||
background: #3c6018;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .skinPrimary {
|
||||
background: #335214;
|
||||
}
|
||||
|
||||
.skinPrimary .title,
|
||||
.skinPrimary .description {
|
||||
color: var(--theme-attention-card-text-color);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 76px;
|
||||
width: 76px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card__link {
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-size-normal);
|
||||
}
|
||||
|
||||
.card__link:not(:last-child) {
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.cards {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="45" height="45" viewBox="0 0 576 512">
|
||||
<!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
|
||||
<path d="M80.3 44C69.8 69.9 64 98.2 64 128s5.8 58.1 16.3 84c6.6 16.4-1.3 35-17.7 41.7s-35-1.3-41.7-17.7C7.4 202.6 0 166.1 0 128S7.4 53.4 20.9 20C27.6 3.6 46.2-4.3 62.6 2.3S86.9 27.6 80.3 44zM555.1 20C568.6 53.4 576 89.9 576 128s-7.4 74.6-20.9 108c-6.6 16.4-25.3 24.3-41.7 17.7S489.1 228.4 495.7 212c10.5-25.9 16.3-54.2 16.3-84s-5.8-58.1-16.3-84C489.1 27.6 497 9 513.4 2.3s35 1.3 41.7 17.7zM352 128c0 23.7-12.9 44.4-32 55.4V480c0 17.7-14.3 32-32 32s-32-14.3-32-32V183.4c-19.1-11.1-32-31.7-32-55.4c0-35.3 28.7-64 64-64s64 28.7 64 64zM170.6 76.8C163.8 92.4 160 109.7 160 128s3.8 35.6 10.6 51.2c7.1 16.2-.3 35.1-16.5 42.1s-35.1-.3-42.1-16.5c-10.3-23.6-16-49.6-16-76.8s5.7-53.2 16-76.8c7.1-16.2 25.9-23.6 42.1-16.5s23.6 25.9 16.5 42.1zM464 51.2c10.3 23.6 16 49.6 16 76.8s-5.7 53.2-16 76.8c-7.1 16.2-25.9 23.6-42.1 16.5s-23.6-25.9-16.5-42.1c6.8-15.6 10.6-32.9 10.6-51.2s-3.8-35.6-10.6-51.2c-7.1-16.2 .3-35.1 16.5-42.1s35.1 .3 42.1 16.5z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,53 +0,0 @@
|
||||
import footerCss from "./footer.module.css";
|
||||
import ActionCard from "../ActionCard";
|
||||
import FossIcon from "./foss.svg";
|
||||
import SubscribeIcon from "./subscribeIcon.svg";
|
||||
import Subscribe from "../Subscribe";
|
||||
import React from "react";
|
||||
import SvgImage from "../SvgImage";
|
||||
|
||||
export const ActionFooter = () => (
|
||||
<div className={footerCss.cards}>
|
||||
<ActionCard
|
||||
icon={
|
||||
<SvgImage
|
||||
image={<FossIcon />}
|
||||
title="An icon showing wave propagation"
|
||||
/>
|
||||
}
|
||||
svgBackgroundColor="#ffffff"
|
||||
title="Join our community"
|
||||
description="Gitea is open source. Star our GitHub repo, and join our community on Discord!"
|
||||
>
|
||||
<a
|
||||
className={footerCss.card__link}
|
||||
href={'https://github.com/go-gitea/gitea'}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Go to GitHub >
|
||||
</a>
|
||||
<a className={footerCss.card__link} href={'https://discord.com/invite/gitea'}>
|
||||
Join Discord >
|
||||
</a>
|
||||
</ActionCard>
|
||||
|
||||
<ActionCard
|
||||
title="Subscribe to our newsletter"
|
||||
description="Stay up to date with all things Gitea"
|
||||
svgBackgroundColor="#1E1F27"
|
||||
icon={
|
||||
<SvgImage
|
||||
image={<SubscribeIcon />}
|
||||
title="An icon showing a paper plane"
|
||||
/>
|
||||
}
|
||||
skin="primary"
|
||||
>
|
||||
<Subscribe
|
||||
placeholder="Email address"
|
||||
submitButtonText = "Subscribe"
|
||||
/>
|
||||
</ActionCard>
|
||||
</div>
|
||||
)
|
||||
@@ -1,27 +0,0 @@
|
||||
.loader {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.loader:after {
|
||||
content: " ";
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid transparent;
|
||||
border-color: var(--ifm-color-white) transparent var(--ifm-color-white)
|
||||
transparent;
|
||||
animation: loader 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes loader {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="45" height="45" viewBox="0 0 512 512">
|
||||
<!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
|
||||
<style>svg{fill:#ffffff}</style>
|
||||
<path d="M16.1 260.2c-22.6 12.9-20.5 47.3 3.6 57.3L160 376V479.3c0 18.1 14.6 32.7 32.7 32.7c9.7 0 18.9-4.3 25.1-11.8l62-74.3 123.9 51.6c18.9 7.9 40.8-4.5 43.9-24.7l64-416c1.9-12.1-3.4-24.3-13.5-31.2s-23.3-7.5-34-1.4l-448 256zm52.1 25.5L409.7 90.6 190.1 336l1.2 1L68.2 285.7zM403.3 425.4L236.7 355.9 450.8 116.6 403.3 425.4z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 623 B |
@@ -1,66 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
const Button = (props) => {
|
||||
const { icon, variant, size, uppercase, className, to, href, children } = props;
|
||||
const classes = clsx(className, styles.button, {
|
||||
[styles["button--icon"]]: icon != null,
|
||||
[styles["button--primary"]]: variant === "primary",
|
||||
[styles["button--secondary"]]: variant === "secondary",
|
||||
[styles["button--small"]]: size === "small",
|
||||
[styles["button--tertiary"]]: variant === "tertiary",
|
||||
[styles["button--plain"]]: variant === "plain",
|
||||
[styles["button--uppercase"]]: uppercase === "true",
|
||||
[styles["button--xsmall"]]: size === "xsmall",
|
||||
[styles["button--xxsmall"]]: size === "xxsmall",
|
||||
})
|
||||
if (href != null) {
|
||||
const { disabled, onClick, newtab} = props;
|
||||
return (
|
||||
<a
|
||||
className={classes}
|
||||
{...(disabled ?? false ? {} : {
|
||||
href,
|
||||
onClick,
|
||||
})}
|
||||
{...(newtab === "true" ? {
|
||||
rel: "noopener noreferrer",
|
||||
target: "_blank",
|
||||
} : {})}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
if (to != null) {
|
||||
return (
|
||||
<a className={classes} href={to} onClick={onClick}>
|
||||
{icon}
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={classes}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
Button.defaultProps = {
|
||||
newtab: "true",
|
||||
size: "normal",
|
||||
uppercase: "true",
|
||||
variant: "primary",
|
||||
}
|
||||
|
||||
export default Button
|
||||
@@ -1,95 +0,0 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 55px;
|
||||
padding: 0 2rem;
|
||||
border: none;
|
||||
border-radius: calc(var(--ifm-global-border-radius) / 2);
|
||||
font-weight: var(--ifm-font-weight-bold);
|
||||
font-size: var(--font-size-normal);
|
||||
transition: background-color 100ms cubic-bezier(0.17, 0.67, 0.83, 0.67);
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button--plain {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
font-weight: unset;
|
||||
font-size: unset;
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
background-color: var(--theme-button-primary-background-color);
|
||||
color: var(--theme-button-primary-text-color); }
|
||||
|
||||
.button--primary:hover {
|
||||
background-color: var(--theme-button-primary-hover-background-color);
|
||||
color: var(--theme-button-primary-text-color);
|
||||
}
|
||||
|
||||
.button--icon img, .button--icon svg {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
background-color: var(--theme-button-secondary-background-color);
|
||||
color: var(--theme-button-secondary-text-color);
|
||||
}
|
||||
|
||||
.button--secondary:hover {
|
||||
background-color: var(--theme-button-secondary-hover-background-color);
|
||||
color: var(--theme-button-secondary-text-color);
|
||||
}
|
||||
|
||||
.button--tertiary {
|
||||
background-color: var(--theme-button-tertiary-background-color);
|
||||
color: var(--theme-button-tertiary-text-color);
|
||||
}
|
||||
|
||||
.button--tertiary:hover {
|
||||
color: var(--theme-button-tertiary-text-color);
|
||||
background-color: var(--theme-button-tertiary-hover-background-color);
|
||||
}
|
||||
|
||||
.button--small {
|
||||
height: 3.5rem;
|
||||
}
|
||||
|
||||
.button--xsmall {
|
||||
height: 2.6rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.button--xxsmall {
|
||||
height: 2rem;
|
||||
font-weight: normal;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.button--uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.button {
|
||||
padding: 0 1.75rem;
|
||||
}
|
||||
|
||||
.button--xsmall {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.button--xxsmall {
|
||||
padding: 0 0.9rem;
|
||||
}
|
||||
|
||||
|
||||
.button--plain {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
import ApiDoc from '@theme/ApiDoc';
|
||||
// For csr api pages
|
||||
export default function ClientOnly(props) {
|
||||
return (
|
||||
<ApiDoc
|
||||
specProps={{
|
||||
url: props.swaggerPath,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import React from 'react';
|
||||
import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem';
|
||||
import {useLocation} from '@docusaurus/router';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
|
||||
// ensure a single trailing slash so paths can be compared as prefixes
|
||||
function withTrailingSlash(path) {
|
||||
return path.endsWith('/') ? path : `${path}/`;
|
||||
}
|
||||
|
||||
// baseUrl contains the locale prefix on localized builds (e.g. "/zh-cn/"),
|
||||
// while the configured item paths never do
|
||||
function stripBaseUrl(pathname, baseUrl) {
|
||||
return pathname.startsWith(baseUrl)
|
||||
? `/${pathname.slice(baseUrl.length)}`
|
||||
: pathname;
|
||||
}
|
||||
|
||||
export default function DropDown(props) {
|
||||
const {pathname} = useLocation();
|
||||
const {siteConfig} = useDocusaurusContext();
|
||||
const {routerRgx, classNames} = props;
|
||||
const r = new RegExp(routerRgx);
|
||||
let isMatched = r.test(pathname);
|
||||
let newLabel = props.label;
|
||||
if (isMatched) {
|
||||
const currentPath = withTrailingSlash(
|
||||
stripBaseUrl(pathname, siteConfig.baseUrl)
|
||||
);
|
||||
// the latest version is served without a version segment (e.g. "/api/"),
|
||||
// so match the longest item path that prefixes the current location
|
||||
// instead of requiring an exact match
|
||||
const bestMatch = props.items
|
||||
.filter(item => currentPath.startsWith(withTrailingSlash(item.to)))
|
||||
.sort((a, b) => b.to.length - a.to.length)[0];
|
||||
newLabel = bestMatch?.label ?? newLabel;
|
||||
}
|
||||
const newProps = {...props, label: newLabel};
|
||||
return (
|
||||
<DropdownNavbarItem {...newProps} className={`custom-dropdown ${classNames}${isMatched ? ' gt-visible': ' gt-hidden'}`}></DropdownNavbarItem>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
const Input = (props) => {
|
||||
const classes = clsx(props.className, styles.input)
|
||||
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={classes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Input.defaultProps = {
|
||||
type: "text",
|
||||
}
|
||||
|
||||
export default Input
|
||||
@@ -1,23 +0,0 @@
|
||||
.input {
|
||||
display: flex;
|
||||
height: 55px;
|
||||
padding: 0 2rem;
|
||||
align-items: center;
|
||||
border-radius: calc(var(--ifm-global-border-radius) / 2);
|
||||
border: none;
|
||||
background: var(--palette-rock);
|
||||
font-size: var(--font-size-normal);
|
||||
color: var(--ifm-color-white);
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ifm-color-white);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--palette-pale-blue);
|
||||
font-size: var(--font-size-normal);
|
||||
font-weight: var(--ifm-font-weight-bold);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
|
||||
export default function Outdated(props) {
|
||||
return (
|
||||
<div className='outdated-text'>
|
||||
<Translate id="outdated.message">
|
||||
The content of current version is not up to date, please check latest English version, or
|
||||
</Translate>
|
||||
<a href={props.editUrl}>
|
||||
<Translate id="outdated.help">
|
||||
Help us to translate
|
||||
</Translate>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from "react";
|
||||
import style from "./styles.module.css";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const Section = ({
|
||||
fullWidth,
|
||||
children,
|
||||
odd,
|
||||
accent,
|
||||
row,
|
||||
noGap,
|
||||
center,
|
||||
className = "",
|
||||
}) => (
|
||||
<div
|
||||
className={clsx(
|
||||
style.root,
|
||||
{
|
||||
[style.odd]: odd,
|
||||
[style.accent]: accent,
|
||||
[style.row]: row,
|
||||
[style.fullWidth]: fullWidth,
|
||||
[style.noGap]: noGap,
|
||||
[style.center]: center,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -1,41 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: var(--ifm-container-width);
|
||||
width: 100%;
|
||||
padding: 2rem 1rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 900px) {
|
||||
.root {
|
||||
padding: 4.5rem 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.odd {
|
||||
background-color: var(--theme-section-odd-bg-color);
|
||||
}
|
||||
|
||||
.accent {
|
||||
--ifm-link-hover-color: var(--palette-pink);
|
||||
--ifm-link-color: var(--palette-pink);
|
||||
padding-top: 7rem;
|
||||
padding-bottom: 7.5rem;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.noGap {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.center {
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import React, { useState } from "react"
|
||||
import Input from "../Input"
|
||||
import Button from "../Button"
|
||||
import style from "./style.module.css"
|
||||
import clsx from "clsx"
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Snackbar from '@mui/material/Snackbar'
|
||||
const Spinner = () => <span className={style.loader} />
|
||||
const Subscribe = ({placeholder, submitButtonText, className, classNameInputs}) => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [inputValue,setInputValue]=useState('')
|
||||
const [toastVisible,setToastVisible]=useState(false)
|
||||
function onSubmit() {
|
||||
setLoading(true)
|
||||
fetch('https://api.hsforms.com/submissions/v3/integration/submit/44783791/7314ddd3-9767-4c71-8071-4d43ac5ae5e8',{
|
||||
method:'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
submittedAt: new Date().getTime(),
|
||||
fields: [
|
||||
{
|
||||
objectTypeId: "0-1",
|
||||
name: "email",
|
||||
value: inputValue
|
||||
}
|
||||
],
|
||||
context: {
|
||||
// hutk: "hutk",
|
||||
pageUri: window.location.href,
|
||||
pageName: document.title
|
||||
}
|
||||
})
|
||||
})
|
||||
.then(res =>res.json())
|
||||
.then((data) => {
|
||||
setLoading(false)
|
||||
setToastVisible(true)
|
||||
setInputValue('')
|
||||
})
|
||||
}
|
||||
function handleInputChange(event) {
|
||||
const value = event.target.value
|
||||
setInputValue(value)
|
||||
}
|
||||
function handleClose (event, reason) {
|
||||
setToastVisible(false)
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(style.inputs, classNameInputs)}>
|
||||
<Input
|
||||
className={style.input}
|
||||
name="email"
|
||||
type="email"
|
||||
title="Email address should be valid"
|
||||
placeholder={placeholder}
|
||||
required
|
||||
autoComplete="off"
|
||||
onChange={handleInputChange}
|
||||
value={inputValue}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant={"tertiary"}
|
||||
type="submit"
|
||||
className={style.subscribeSubmit}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{loading ? <Spinner /> : submitButtonText}
|
||||
</Button>
|
||||
<Snackbar anchorOrigin={{ vertical: 'top',horizontal: 'center' }} autoHideDuration={3000} open={toastVisible} onClose={handleClose}>
|
||||
<Alert onClose={handleClose} severity="success">
|
||||
success!
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Subscribe
|
||||
@@ -1,75 +0,0 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputs {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
:global(html[data-theme="light"]) .subscribeSubmit {
|
||||
background: #dde0e9;
|
||||
}
|
||||
|
||||
:global(html[data-theme="light"]) .subscribeSubmit:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 600px) {
|
||||
.inputs {
|
||||
grid-template-columns: 4fr 2fr;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
color: var(--theme-input-text-color);
|
||||
padding: 1rem;
|
||||
font-size: var(--font-size-small);
|
||||
width: 100%;
|
||||
background-color: var(--theme-input-bg-color);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--theme-input-text-color);
|
||||
}
|
||||
|
||||
.submit {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loader {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.loader:after {
|
||||
content: " ";
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid transparent;
|
||||
border-color: var(--ifm-color-white) transparent var(--ifm-color-white)
|
||||
transparent;
|
||||
animation: loader 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.success {
|
||||
font-size: var(--font-size-large);
|
||||
font-weight: var(--ifm-font-weight-bold);
|
||||
}
|
||||
|
||||
@keyframes loader {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { cloneElement } from "react";
|
||||
|
||||
const SvgImage = ({ image, title = "" }) =>
|
||||
cloneElement(image, {
|
||||
...image.props,
|
||||
title,
|
||||
})
|
||||
|
||||
export default SvgImage
|
||||
@@ -1,272 +0,0 @@
|
||||
/**
|
||||
* Any CSS included here will be global. The classic template
|
||||
* bundles Infima by default. Infima is a CSS framework designed to
|
||||
* work well for content-centric websites.
|
||||
*/
|
||||
|
||||
/* You can override the default Infima variables here. */
|
||||
:root {
|
||||
--font-size-small: 15px;
|
||||
--font-size-normal: 16px;
|
||||
--font-size-large: 17px;
|
||||
--font-size-big-1: 22px;
|
||||
--font-size-big-2: 24px;
|
||||
--font-size-big-3: 32px;
|
||||
--font-size-big-4: 46px;
|
||||
--font-size-big-5: 64px;
|
||||
--palette-dark-10: rgba(0, 0, 0, 0.1);
|
||||
--palette-dark-20: rgba(0, 0, 0, 0.2);
|
||||
--palette-dark-30: rgba(0, 0, 0, 0.3);
|
||||
--palette-dark-40: rgba(0, 0, 0, 0.4);
|
||||
--palette-dark-60: rgba(0, 0, 0, 0.6);
|
||||
--palette-dark-80: rgba(0, 0, 0, 0.8);
|
||||
--palette-white-10: rgba(255, 255, 255, 0.1);
|
||||
--palette-white-20: rgba(255, 255, 255, 0.2);
|
||||
--palette-charade: #21222c;
|
||||
--palette-rock: #262833;
|
||||
--palette-pale-blue: #b1b5d3;
|
||||
--ifm-color-primary: #4183c4;
|
||||
--ifm-color-primary-dark: #3a76b0;
|
||||
--ifm-color-primary-darker: #356ba0;
|
||||
--ifm-color-primary-darkest: #2b5680;
|
||||
--ifm-color-primary-light: #498fce;
|
||||
--ifm-color-primary-lighter: #5498d3;
|
||||
--ifm-color-primary-lightest: #6aa7db;
|
||||
--ifm-code-font-size: 95%;
|
||||
--ifm-global-border-radius: 8px;
|
||||
--docusaurus-highlighted-code-line-bg: var(--palette-dark-10);
|
||||
--theme-card-text-color: var(--palette-charade);
|
||||
--theme-card-title-color: var(--palette-charade);
|
||||
--theme-attention-card-bg-color: #f0f1f5;
|
||||
--theme-attention-card-text-color: var(--ifm-color-white);
|
||||
--theme-input-text-color: #555b88;
|
||||
--theme-card-secondary-bg-color: #dde0e9;
|
||||
--theme-button-primary-background-color: var(--ifm-color-primary);
|
||||
--theme-button-primary-text-color: var(--ifm-color-white);
|
||||
--theme-button-primary-hover-background-color: var(--ifm-color-primary-darker);
|
||||
--theme-button-secondary-background-color: var(--palette-dark-10);
|
||||
--theme-button-secondary-text-color: var(--palette-charade);
|
||||
--theme-button-secondary-hover-background-color: var(--palette-dark-30);
|
||||
--theme-button-tertiary-background-color: var(--palette-dark-30);
|
||||
--theme-button-tertiary-text-color: var(--palette-dark-80);
|
||||
--theme-button-tertiary-hover-background-color: var(--palette-dark-40);
|
||||
--theme-input-bg-color: #f0f1f5;
|
||||
--ifm-navbar-link-active-color: #2f7d1f;
|
||||
--ifm-menu-color-active: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||
[data-theme='dark'] {
|
||||
--palette-gray: #4b4e5d;
|
||||
--ifm-color-primary: #6aa7db;
|
||||
--ifm-color-primary-dark: #5f97c6;
|
||||
--ifm-color-primary-darker: #578bb8;
|
||||
--ifm-color-primary-darkest: #466f92;
|
||||
--ifm-color-primary-light: #77b1df;
|
||||
--ifm-color-primary-lighter: #85b9e3;
|
||||
--ifm-color-primary-lightest: #9cc7ea;
|
||||
--docusaurus-highlighted-code-line-bg: var(--palette-dark-30);
|
||||
--theme-attention-card-bg-color: var(--palette-gray);
|
||||
--theme-input-bg-color: #44475a;
|
||||
--theme-input-text-color: #b1b5d3;
|
||||
--theme-card-secondary-bg-color: var(--palette-charade);
|
||||
--theme-button-primary-background-color: var(--ifm-color-primary);
|
||||
--theme-button-primary-text-color: var(--ifm-color-white);
|
||||
--theme-button-primary-hover-background-color: var(--ifm-color-primary-darker);
|
||||
--theme-button-secondary-background-color: var(--ifm-color-white);
|
||||
--theme-button-secondary-text-color: var(--palette-charade);
|
||||
--theme-button-secondary-hover-background-color: #d9d9d9;
|
||||
--theme-button-tertiary-background-color: var(--palette-white-10);
|
||||
--theme-button-tertiary-text-color: var(--ifm-color-white);
|
||||
--theme-button-tertiary-hover-background-color: var(--palette-white-20);
|
||||
--ifm-navbar-link-active-color: #9bdc63;
|
||||
--ifm-menu-color-active: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
[data-theme='dark'] [class*='announcementBar'] {
|
||||
color: var(--ifm-font-color-base);
|
||||
background-color: var(--ifm-background-color);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .close {
|
||||
color: var(--ifm-color-white);
|
||||
}
|
||||
|
||||
.outdated-text {
|
||||
margin: 20px 0;
|
||||
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
font-family: Roboto, Helvetica, Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.43;
|
||||
letter-spacing: 0.01071em;
|
||||
background-color: rgb(229, 246, 253);
|
||||
display: flex;
|
||||
padding: 6px 16px;
|
||||
color: rgb(1, 67, 97);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .outdated-text {
|
||||
background-color: rgb(7, 19, 24);
|
||||
color: rgb(184, 231, 251);
|
||||
}
|
||||
|
||||
.redocusaurus .menu-content {
|
||||
top: 70px !important;
|
||||
height: calc(100vh - 70px) !important;
|
||||
}
|
||||
|
||||
/* Redoc's own `SearchResultsBox` styles are broken by a stray `}` after the
|
||||
`background-color` interpolation, so every declaration after it (including
|
||||
`max-height`) is dropped. Without a height limit the results box grows to the
|
||||
full result list height, perfect-scrollbar sees no overflow and renders no
|
||||
scrollbar, and the sidebar (overflow: hidden) clips the results.
|
||||
Restore the intended styles until it is fixed upstream.
|
||||
Upstream PR to fix this is: https://github.com/Redocly/redoc/pull/2819
|
||||
*/
|
||||
.redocusaurus [role='search'] [data-role='search:results'] {
|
||||
min-height: 150px;
|
||||
max-height: 250px;
|
||||
line-height: 1.4;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.redocusaurus [role='search'] [data-role='search:results'] label {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.gt-hidden {
|
||||
display: none;
|
||||
}
|
||||
/* The following css is for toggling API version dropdown/menu,
|
||||
TODO: need to find a proper way to customize the classname
|
||||
*/
|
||||
@supports selector(:has(*)) {
|
||||
/* Do not show doc search on api pages */
|
||||
body:has(.redocusaurus) [class*='searchBox'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 996px) {
|
||||
/* hide other dropdowns except for api dropdown on api pages */
|
||||
body:has(.redocusaurus) .navbar__item.dropdown:not(:has(.api-dropdown)) {
|
||||
display: none;
|
||||
}
|
||||
/* hide other dropdowns except for runner dropdown on runner pages */
|
||||
body:has(.runner-dropdown.gt-visible) .navbar__item.dropdown:not(:has(.runner-dropdown)) {
|
||||
display: none;
|
||||
}
|
||||
/* hide dropdown menus that have sub-elements containing 'gt-hidden' */
|
||||
body .navbar__item.dropdown:has(.custom-dropdown.gt-hidden) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
/* on mobile, dropdown becomes menu list */
|
||||
/* Hide collapsible menus except for API menu on API pages */
|
||||
body:has(.redocusaurus) .menu__list-item.menu__list-item--collapsed:not(:has(.api-dropdown)) {
|
||||
display: none;
|
||||
}
|
||||
/* Hide collapsible menus except for Runner menu on Runner pages */
|
||||
body:has(.runner-dropdown.gt-visible) .menu__list-item.menu__list-item--collapsed:not(:has(.runner-dropdown)) {
|
||||
display: none;
|
||||
}
|
||||
/* Hide dropdown menus that have sub-elements containing 'gt-hidden' */
|
||||
body .menu__list-item.menu__list-item--collapsed:has(.custom-dropdown.gt-hidden) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* selectors like :nth-of-type are for browsers those do not support :has */
|
||||
@supports not (selector(:has(*))) {
|
||||
.plugin-redoc [class*='searchBox'],
|
||||
.plugin-pages [class*='searchBox'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 996px) {
|
||||
.plugin-redoc .navbar__item.dropdown:not(:nth-of-type(4)),
|
||||
.plugin-pages .navbar__item.dropdown:not(:nth-of-type(4)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.plugin-redoc .menu__list-item.menu__list-item--collapsed:not(:nth-of-type(3)),
|
||||
.plugin-pages .menu__list-item.menu__list-item--collapsed:not(:nth-of-type(3)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.internal-href [class*='iconExternalLink'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar__link--active,
|
||||
.navbar__link[aria-current='page'] {
|
||||
color: var(--ifm-navbar-link-active-color) !important;
|
||||
}
|
||||
|
||||
.signin-button {
|
||||
--bs-btn-padding-x: .75rem;
|
||||
--bs-btn-padding-y: .375rem;
|
||||
--bs-btn-font-family: ;
|
||||
--bs-btn-font-size: 1rem;
|
||||
--bs-btn-font-weight: 400;
|
||||
--bs-btn-line-height: 1.5;
|
||||
--bs-btn-bg: transparent;
|
||||
--bs-btn-border-width: 1px;
|
||||
--bs-btn-border-radius: .375rem;
|
||||
--bs-btn-box-shadow: inset 0 1px 0 rgba(255,255,255,0.15),0 1px 1px rgba(29,45,53,0.075);
|
||||
--bs-btn-disabled-opacity: .65;
|
||||
--bs-btn-focus-box-shadow: 0 0 0 0 rgba(var(--bs-btn-focus-shadow-rgb), .5);
|
||||
--bs-btn-color: #198754;
|
||||
--bs-btn-border-color: #198754;
|
||||
--bs-btn-hover-color: #fff;
|
||||
--bs-btn-hover-bg: #198754;
|
||||
--bs-btn-hover-border-color: #198754;
|
||||
--bs-btn-focus-shadow-rgb: 25,135,84;
|
||||
--bs-btn-active-color: #fff;
|
||||
--bs-btn-active-bg: #198754;
|
||||
--bs-btn-active-border-color: #198754;
|
||||
--bs-btn-active-shadow: inset 0 3px 5px rgba(29,45,53,0.125);
|
||||
--bs-btn-disabled-color: #198754;
|
||||
--bs-btn-disabled-bg: transparent;
|
||||
--bs-gradient: none;
|
||||
display: inline-block;
|
||||
padding: var(--bs-btn-padding-y) var(--bs-btn-padding-x);
|
||||
font-family: var(--bs-btn-font-family);
|
||||
font-size: var(--bs-btn-font-size);
|
||||
font-weight: var(--bs-btn-font-weight);
|
||||
line-height: var(--bs-btn-line-height);
|
||||
color: var(--bs-btn-color);
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
border: var(--bs-btn-border-width) solid var(--bs-btn-border-color);
|
||||
border-radius: var(--bs-btn-border-radius);
|
||||
background-color: var(--bs-btn-bg);
|
||||
transition: color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out;
|
||||
margin-left: var(--ifm-navbar-item-padding-horizontal);
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.signin-button:hover {
|
||||
color: var(--bs-btn-hover-color);
|
||||
background-color: var(--bs-btn-hover-bg);
|
||||
border-color: var(--bs-btn-hover-border-color);
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.navbar__item.signin-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from 'react';
|
||||
import Layout from '@theme/Layout';
|
||||
import Redoc from '@theme/Redoc';
|
||||
import { ActionFooter } from "@site/src/components/ActionFooter";
|
||||
import { Section } from "@site/src/components/Section";
|
||||
function ApiDoc({ layoutProps, specProps }) {
|
||||
const defaultTitle = specProps.spec?.info?.title || 'API Docs';
|
||||
const defaultDescription = specProps.spec?.info?.description || 'Open API Reference Docs for the API';
|
||||
return (<Layout title={defaultTitle} description={defaultDescription} {...layoutProps}>
|
||||
<Redoc {...specProps}/>
|
||||
<Section>
|
||||
<ActionFooter />
|
||||
</Section>
|
||||
</Layout>);
|
||||
}
|
||||
export default ApiDoc;
|
||||
@@ -1,2 +0,0 @@
|
||||
import ApiDoc from './ApiDoc';
|
||||
export default ApiDoc;
|
||||
@@ -1,36 +0,0 @@
|
||||
// Ejected unsafe, need to check if this changes and maintain this component
|
||||
// https://github.com/facebook/docusaurus/blob/docusaurus-v2/packages/docusaurus-theme-classic/src/theme/DocPage/Layout/index.tsx
|
||||
import React, {useState} from 'react';
|
||||
import {useDocsSidebar} from '@docusaurus/plugin-content-docs/client';
|
||||
import Layout from '@theme/Layout';
|
||||
import BackToTopButton from '@theme/BackToTopButton';
|
||||
import DocPageLayoutSidebar from '@theme/DocPage/Layout/Sidebar';
|
||||
import DocPageLayoutMain from '@theme/DocPage/Layout/Main';
|
||||
import styles from './styles.module.css';
|
||||
import { ActionFooter } from "@site/src/components/ActionFooter";
|
||||
import { Section } from "@site/src/components/Section";
|
||||
|
||||
export default function DocPageLayout({children}) {
|
||||
const sidebar = useDocsSidebar();
|
||||
const [hiddenSidebarContainer, setHiddenSidebarContainer] = useState(false);
|
||||
return (
|
||||
<Layout wrapperClassName={styles.docsWrapper}>
|
||||
<BackToTopButton />
|
||||
<div className={styles.docPage}>
|
||||
{sidebar && (
|
||||
<DocPageLayoutSidebar
|
||||
sidebar={sidebar.items}
|
||||
hiddenSidebarContainer={hiddenSidebarContainer}
|
||||
setHiddenSidebarContainer={setHiddenSidebarContainer}
|
||||
/>
|
||||
)}
|
||||
<DocPageLayoutMain hiddenSidebarContainer={hiddenSidebarContainer}>
|
||||
{children}
|
||||
</DocPageLayoutMain>
|
||||
</div>
|
||||
<Section>
|
||||
<ActionFooter />
|
||||
</Section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.docPage {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.docsWrapper {
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import React from 'react';
|
||||
import Link from '@docusaurus/Link';
|
||||
import {useDoc} from '@docusaurus/plugin-content-docs/client';
|
||||
|
||||
export default function MDXA(props) {
|
||||
// {assets, contentTitle, frontMatter, metadata, toc}
|
||||
const {metadata} = useDoc();
|
||||
let newProps = {...props};
|
||||
if (metadata.version !== 'current' && (props.href.startsWith('https://github.com/go-gitea/gitea/blob/main'))) {
|
||||
const versionedHref = props.href.replace('main', `release/v${metadata.version}`);
|
||||
newProps = {...props, href: versionedHref};
|
||||
}
|
||||
return <Link {...newProps} />;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import MDXComponents from '@theme-original/MDXComponents';
|
||||
import A from './A';
|
||||
|
||||
export default {
|
||||
...MDXComponents,
|
||||
a: A,
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from 'react';
|
||||
import {MDXProvider} from '@mdx-js/react';
|
||||
import MDXComponents from '@theme/MDXComponents';
|
||||
// useDoc reference:
|
||||
// https://fossies.org/linux/docusaurus/packages/docusaurus-theme-classic/src/theme/DocItem/Content/index.tsx
|
||||
import {useDoc} from '@docusaurus/plugin-content-docs/client';
|
||||
import Outdated from '@site/src/components/Outdated';
|
||||
|
||||
export default function MDXContent({children}) {
|
||||
// {assets, contentTitle, frontMatter, metadata, toc}
|
||||
const {frontMatter, metadata} = useDoc();
|
||||
return <MDXProvider components={MDXComponents}>
|
||||
{frontMatter.isOutdated && <Outdated editUrl={metadata.editUrl}/>}
|
||||
{children}
|
||||
</MDXProvider>;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import DefaultNavbarItem from '@theme/NavbarItem/DefaultNavbarItem';
|
||||
import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem';
|
||||
import LocaleDropdownNavbarItem from '@theme/NavbarItem/LocaleDropdownNavbarItem';
|
||||
import SearchNavbarItem from '@theme/NavbarItem/SearchNavbarItem';
|
||||
import HtmlNavbarItem from '@theme/NavbarItem/HtmlNavbarItem';
|
||||
import DocNavbarItem from '@theme/NavbarItem/DocNavbarItem';
|
||||
import DocSidebarNavbarItem from '@theme/NavbarItem/DocSidebarNavbarItem';
|
||||
import DocsVersionNavbarItem from '@theme/NavbarItem/DocsVersionNavbarItem';
|
||||
import DocsVersionDropdownNavbarItem from '@theme/NavbarItem/DocsVersionDropdownNavbarItem';
|
||||
import DropDown from '@site/src/components/DropDown';
|
||||
|
||||
const ComponentTypes = {
|
||||
default: DefaultNavbarItem,
|
||||
localeDropdown: LocaleDropdownNavbarItem,
|
||||
search: SearchNavbarItem,
|
||||
dropdown: DropdownNavbarItem,
|
||||
html: HtmlNavbarItem,
|
||||
doc: DocNavbarItem,
|
||||
docSidebar: DocSidebarNavbarItem,
|
||||
docsVersion: DocsVersionNavbarItem,
|
||||
docsVersionDropdown: DocsVersionDropdownNavbarItem,
|
||||
'custom-Dropdown': DropDown,
|
||||
};
|
||||
export default ComponentTypes;
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from 'react';
|
||||
import Content from '@theme-original/NotFound/Content';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
export default function ContentWrapper(props) {
|
||||
return (
|
||||
<>
|
||||
<Content {...props} />
|
||||
<div className={styles.links}>
|
||||
<a href="/">Browse the latest docs</a>
|
||||
<span className={styles.separator}/>
|
||||
<a href="/next">Access next docs</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
.links {
|
||||
width: 100%;
|
||||
margin-top: -80px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.separator{
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background-color: black;
|
||||
}
|
||||
@@ -13,8 +13,8 @@
|
||||
# runner-docs_versioned_docs/version-<series>/reference directories, and the tag
|
||||
# each one is generated from is the newest stable v<series>.x.y tag of
|
||||
# gitea/runner, looked up through the Gitea API. A new series is documented by
|
||||
# running `pnpm run docusaurus docs:version:runner-docs <series>`, nothing here
|
||||
# has to be edited.
|
||||
# running `make cut-version PRODUCT=runner VERSION=<series>`, nothing here has
|
||||
# to be edited.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
Reference in New Issue
Block a user