mirror of
https://gitea.com/gitea/docs.git
synced 2026-11-13 21:33:44 +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 parent
c78e0d42a9
commit
b137a0e0e1
99 files changed
+6701
-13674
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
name: Build and Publish Docs site
|
name: Build and Publish Docs site
|
||||||
run-name: docusaurus build docs site
|
run-name: astro build docs site
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -9,6 +9,13 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build-docs:
|
build-docs:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
|
# search only appears once the credentials are set, the build falls back
|
||||||
|
# to pagefind otherwise
|
||||||
|
PUBLIC_DOCSEARCH_APP_ID: ${{ secrets.DOCSEARCH_APP_ID }}
|
||||||
|
PUBLIC_DOCSEARCH_API_KEY: ${{ secrets.DOCSEARCH_API_KEY }}
|
||||||
|
PUBLIC_DOCSEARCH_INDEX_NAME: ${{ secrets.DOCSEARCH_INDEX_NAME }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||||
@@ -23,14 +30,13 @@ jobs:
|
|||||||
sudo ./aws/install
|
sudo ./aws/install
|
||||||
- name: prepare awesome list
|
- name: prepare awesome list
|
||||||
run: |
|
run: |
|
||||||
make prepare-awesome-latest prepare-awesome\#25 prepare-awesome\#24 prepare-awesome\#23 prepare-awesome\#22
|
make prepare-awesome-latest prepare-awesome\#27 prepare-awesome\#26 prepare-awesome\#25 prepare-awesome\#24 prepare-awesome\#23 prepare-awesome\#22
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
#- uses: tats-u/docuactions-cache@v1
|
|
||||||
- name: build site
|
- name: build site
|
||||||
run: |
|
run: make build
|
||||||
make build
|
|
||||||
- name: aws credential configure
|
- name: aws credential configure
|
||||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
|
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
|
||||||
with:
|
with:
|
||||||
@@ -39,20 +45,21 @@ jobs:
|
|||||||
aws-region: ${{ secrets.AWS_REGION}}
|
aws-region: ${{ secrets.AWS_REGION}}
|
||||||
- name: Copy files to the production website with the AWS CLI
|
- name: Copy files to the production website with the AWS CLI
|
||||||
run: |
|
run: |
|
||||||
aws s3 sync build/ s3://docs-gitea-com
|
aws s3 sync sites/docs/dist/ s3://docs-gitea-com
|
||||||
aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_DISTRIBUTION}} --paths '/*'
|
aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_DISTRIBUTION}} --paths '/*'
|
||||||
- name: Copy files to Cloudflare Pages
|
- name: Copy files to Cloudflare Pages
|
||||||
env:
|
env:
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
# cloudflare/_headers is only meaningful for the Cloudflare Pages
|
# cloudflare/_headers and cloudflare/_redirects are only meaningful for
|
||||||
# deployment, so it is copied into build/ here, after the S3 sync
|
# the Cloudflare Pages deployment, so they are copied into the build
|
||||||
# step above has already run. If it were placed in build/ any
|
# here, after the S3 sync above has run. If they were placed in the
|
||||||
# earlier, that S3 sync would publish it to S3/CloudFront and make
|
# build any earlier, that sync would publish them to S3/CloudFront and
|
||||||
# it publicly accessible at /_headers there.
|
# make them publicly readable there.
|
||||||
cp cloudflare/_headers build/_headers
|
cp cloudflare/_headers sites/docs/dist/_headers
|
||||||
test -f build/_headers || exit 1
|
cp cloudflare/_redirects sites/docs/dist/_redirects
|
||||||
pnpm dlx wrangler@4 pages deploy build \
|
test -f sites/docs/dist/_headers -a -f sites/docs/dist/_redirects || exit 1
|
||||||
|
pnpm dlx wrangler@4 pages deploy sites/docs/dist \
|
||||||
--project-name docs-gitea-com \
|
--project-name docs-gitea-com \
|
||||||
--branch main
|
--branch main
|
||||||
@@ -11,6 +11,8 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
build-docs:
|
build-docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||||
@@ -20,14 +22,17 @@ jobs:
|
|||||||
cache: pnpm
|
cache: pnpm
|
||||||
- name: prepare awesome list
|
- name: prepare awesome list
|
||||||
run: |
|
run: |
|
||||||
make prepare-awesome-latest prepare-awesome\#25 prepare-awesome\#24 prepare-awesome\#23 prepare-awesome\#22
|
make prepare-awesome-latest prepare-awesome\#27 prepare-awesome\#26 prepare-awesome\#25 prepare-awesome\#24 prepare-awesome\#23 prepare-awesome\#22
|
||||||
- name: Install dependencies
|
|
||||||
|
- name: install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
#- uses: tats-u/docuactions-cache@v1
|
|
||||||
- name: build site
|
- name: build site
|
||||||
run: |
|
run: make build
|
||||||
make build
|
|
||||||
|
- name: type check
|
||||||
|
run: make check
|
||||||
|
|
||||||
- name: deploy the preview to Cloudflare Pages
|
- name: deploy the preview to Cloudflare Pages
|
||||||
id: preview
|
id: preview
|
||||||
env:
|
env:
|
||||||
@@ -44,11 +49,13 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# same as the production deployment: _headers only belongs to the
|
# same as the production deployment: _headers and _redirects only
|
||||||
# Cloudflare Pages output, so it is copied in right before uploading
|
# belong to the Cloudflare Pages output, so they are copied in right
|
||||||
cp cloudflare/_headers build/_headers
|
# before uploading
|
||||||
|
cp cloudflare/_headers sites/docs/dist/_headers
|
||||||
|
cp cloudflare/_redirects sites/docs/dist/_redirects
|
||||||
|
|
||||||
pnpm dlx wrangler@4 pages deploy build \
|
pnpm dlx wrangler@4 pages deploy sites/docs/dist \
|
||||||
--project-name docs-gitea-com \
|
--project-name docs-gitea-com \
|
||||||
--branch "pr-$PR_NUMBER" | tee wrangler.log
|
--branch "pr-$PR_NUMBER" | tee wrangler.log
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -1,12 +1,12 @@
|
|||||||
# Dependencies
|
# Dependencies
|
||||||
/node_modules
|
node_modules
|
||||||
|
|
||||||
# Production
|
# Build output
|
||||||
/build
|
sites/*/dist
|
||||||
|
sites/*/.astro
|
||||||
# Generated files
|
sites/*/.cache
|
||||||
.docusaurus
|
build/
|
||||||
.cache-loader
|
.docusaurus/
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
export NODE_OPTIONS := "--max-old-space-size=8192"
|
|
||||||
|
|
||||||
GITEA_AWESOME_REMOTE := https://gitea.com/gitea/awesome-gitea.git
|
GITEA_AWESOME_REMOTE := https://gitea.com/gitea/awesome-gitea.git
|
||||||
GITEA_AWESOME_BRANCH := main
|
GITEA_AWESOME_BRANCH := main
|
||||||
|
|
||||||
@@ -27,26 +25,41 @@ install:
|
|||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
.PHONY: prepare-docs
|
.PHONY: prepare-docs
|
||||||
prepare-docs: install prepare-awesome-latest prepare-awesome\#19 prepare-awesome\#20 prepare-awesome\#21 prepare-awesome\#22 prepare-awesome\#23 prepare-awesome\#24
|
prepare-docs: install prepare-awesome-latest prepare-awesome\#22 prepare-awesome\#23 prepare-awesome\#24 prepare-awesome\#25 prepare-awesome\#26 prepare-awesome\#27
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build:
|
build:
|
||||||
pnpm run build
|
pnpm run build
|
||||||
|
|
||||||
|
# type checks the astro site and its components
|
||||||
|
.PHONY: check
|
||||||
|
check:
|
||||||
|
pnpm run check
|
||||||
|
|
||||||
.PHONY: serve
|
.PHONY: serve
|
||||||
serve: prepare-docs
|
serve: prepare-docs
|
||||||
pnpm run start
|
pnpm run dev
|
||||||
|
|
||||||
.PHONY: serve-zh
|
# only the english docs of the version served at the root, plus the runner:
|
||||||
serve-zh: prepare-docs
|
# starts in a few seconds instead of loading the whole matrix
|
||||||
pnpm run start -- --locale zh-cn
|
.PHONY: serve-fast
|
||||||
|
serve-fast:
|
||||||
|
pnpm run dev:en-latest
|
||||||
|
|
||||||
|
# search is built by pagefind at build time, so it is only available on the
|
||||||
|
# built site; this serves it locally
|
||||||
|
.PHONY: serve-built
|
||||||
|
serve-built: build
|
||||||
|
pnpm run preview
|
||||||
|
|
||||||
# static/swagger-*.json are committed files, use update-api-docs to refresh them
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
rm -rf .tmp
|
rm -rf .tmp
|
||||||
rm -rf static/_*
|
rm -rf sites/docs/dist
|
||||||
|
rm -rf sites/docs/.cache
|
||||||
|
rm -rf sites/docs/node_modules/.astro
|
||||||
|
|
||||||
|
# static/swagger-*.json are committed files, use update-api-docs to refresh them
|
||||||
.PHONY: update-api-docs
|
.PHONY: update-api-docs
|
||||||
update-api-docs:
|
update-api-docs:
|
||||||
./update_api_docs.sh
|
./update_api_docs.sh
|
||||||
@@ -67,3 +80,10 @@ update-runner-docs:
|
|||||||
.PHONY: update-runner-docs-released
|
.PHONY: update-runner-docs-released
|
||||||
update-runner-docs-released:
|
update-runner-docs-released:
|
||||||
./update_runner_docs.sh --released
|
./update_runner_docs.sh --released
|
||||||
|
|
||||||
|
# freezes the current docs or runner tree as a new version, see
|
||||||
|
# scripts/cut-version.mjs
|
||||||
|
.PHONY: cut-version
|
||||||
|
cut-version:
|
||||||
|
@test -n "$(PRODUCT)" -a -n "$(VERSION)" || { echo 'usage: make cut-version PRODUCT=docs VERSION=1.28'; exit 1; }
|
||||||
|
node scripts/cut-version.mjs $(PRODUCT) $(VERSION)
|
||||||
@@ -1,65 +1,99 @@
|
|||||||
# Gitea Docs 
|
# 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
|
The site covers three products, all served from this repository:
|
||||||
make clean
|
|
||||||
make prepare-docs
|
| Product | Content | Versions | Languages |
|
||||||
make build
|
| --- | --- | --- | --- |
|
||||||
```
|
| Docs | `docs/`, `versioned_docs/`, `i18n/` | next, 1.27 … 1.22 | English, 简体中文, 繁體中文 |
|
||||||
|
| API | `static/swagger-*.json` | next, 1.27 … 1.22 | English |
|
||||||
|
| Runner | `runner-docs/`, `runner-docs_versioned_docs/` | develop, 3 … 0 | English |
|
||||||
|
|
||||||
|
The enterprise documentation is built and deployed from its own repository and
|
||||||
|
reached through `/enterprise/`, which a Cloudflare worker maintained elsewhere
|
||||||
|
routes to that deployment.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
make clean
|
make install # pnpm install
|
||||||
make prepare-docs
|
make serve-fast # english, the version served at the root, starts in seconds
|
||||||
make serve
|
make serve # the whole matrix, every version and language
|
||||||
|
make build # production build into sites/docs/dist
|
||||||
|
make check # type checks the site and its components
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test en version
|
Search is built by Pagefind while the site is built, so it only answers on the
|
||||||
|
built site: use `make serve-built` to try it. With the Algolia credentials
|
||||||
|
(`PUBLIC_DOCSEARCH_APP_ID`, `PUBLIC_DOCSEARCH_API_KEY`) set, DocSearch is used
|
||||||
|
instead and works in `make serve` too.
|
||||||
|
|
||||||
|
`GITEA_DOCS_PRODUCTS`, `GITEA_DOCS_VERSIONS` and `GITEA_DOCS_LOCALES` (comma
|
||||||
|
separated) restrict what is built, which is what `make serve-fast` uses.
|
||||||
|
`GITEA_DOCS_STRICT_LINKS=true` turns the warnings about unresolved relative
|
||||||
|
markdown links into a build error.
|
||||||
|
|
||||||
|
`sites/docs/README.md` documents how the sources are mapped onto the site.
|
||||||
|
|
||||||
|
## Writing
|
||||||
|
|
||||||
|
Pages are plain markdown with the same frontmatter and admonitions as before:
|
||||||
|
frontmatter `slug`, `sidebar_position` and `sidebar_label` keep working, and so
|
||||||
|
do the `:::note` style admonitions and the `@version@` style release variables.
|
||||||
|
Relative `*.md` links are rewritten to urls while the site is built.
|
||||||
|
|
||||||
|
The order of the top level sidebar groups comes from `sidebars.js` (and
|
||||||
|
`versioned_sidebars/` for released versions), the label and order of every other
|
||||||
|
group from the `_category_.json` of its directory.
|
||||||
|
|
||||||
|
## Cutting a version
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
pnpm run start
|
make cut-version PRODUCT=docs VERSION=1.28
|
||||||
|
make cut-version PRODUCT=runner VERSION=4
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This freezes the current tree, its translations and its sidebar. The label of
|
||||||
|
the version, its release variables (`@version@`, `@dockerVersion@`, ...) and
|
||||||
|
which version is served at the root live in
|
||||||
|
`packages/content-loader/src/products.ts` and are edited by hand afterwards.
|
||||||
|
That file is the single source of truth for the product, version and language
|
||||||
|
matrix.
|
||||||
|
|
||||||
## API docs
|
## API docs
|
||||||
|
|
||||||
The swagger definitions rendered under `/api-docs` live in `static/swagger-latest.json`
|
The swagger definitions rendered under `/api/` live in
|
||||||
(gitea main) and `static/swagger-<minor>.json` (released versions).
|
`static/swagger-latest.json` (gitea main) and `static/swagger-<minor>.json`
|
||||||
|
(released versions).
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
make update-api-docs # refresh latest + every released version
|
make update-api-docs # refresh latest + every released version
|
||||||
make update-api-docs-latest # refresh only static/swagger-latest.json
|
make update-api-docs-latest # refresh only static/swagger-latest.json
|
||||||
```
|
```
|
||||||
|
|
||||||
`static/swagger-latest.json` is refreshed automatically: the `update swagger files`
|
`static/swagger-latest.json` is refreshed automatically: the `update swagger
|
||||||
workflow runs every 12 hours and opens a pull request whenever gitea main changed.
|
files` workflow runs every 12 hours and opens a pull request whenever gitea main
|
||||||
Released versions are updated by hand when a new gitea version is documented.
|
changed. Released versions are updated by hand when a new gitea version is
|
||||||
|
documented.
|
||||||
|
|
||||||
## Runner docs
|
## Runner docs
|
||||||
|
|
||||||
The runner documentation is a second docs plugin, served under `/runner`:
|
|
||||||
|
|
||||||
| Version | Content | URL |
|
| Version | Content | URL |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| develop | `runner-docs/` | `/runner/develop/` |
|
| develop | `runner-docs/` | `/runner/develop/` |
|
||||||
| current series | `runner-docs_versioned_docs/version-3/` | `/runner/` |
|
| current series | `runner-docs_versioned_docs/version-3/` | `/runner/` |
|
||||||
| older series | `runner-docs_versioned_docs/version-2/` | `/runner/2/` |
|
| older series | `runner-docs_versioned_docs/version-2/` | `/runner/2/` |
|
||||||
| archived series | `runner-docs_versioned_docs/version-1/` | `/runner/1/` |
|
|
||||||
|
|
||||||
A version directory covers a whole release series (`version-3` documents every
|
A version directory covers a whole release series (`version-3` documents every
|
||||||
`3.x` release), so a patch release only needs a content update, not a new folder.
|
`3.x` release), so a patch release only needs a content update, not a new
|
||||||
The UI labels a series `3.x`, derived from `runner-docs_versions.json`, so no
|
folder. Use floating image tags (`gitea/runner:3`) and links to the runner's
|
||||||
version number has to be bumped anywhere on a runner release. Use floating image
|
`main` branch in those pages to keep them valid across patch releases.
|
||||||
tags (`gitea/runner:3`) and links to the runner's `main` branch in those pages to
|
|
||||||
keep them valid across patch releases.
|
|
||||||
|
|
||||||
Its sidebar is written by hand: `runner-sidebars.js` for develop, and
|
Its sidebar is written by hand: `runner-sidebars.js` for develop, and
|
||||||
`runner-docs_versioned_sidebars/version-<version>-sidebars.json` per documented
|
`runner-docs_versioned_sidebars/version-<version>-sidebars.json` per documented
|
||||||
version. The version list lives in `runner-docs_versions.json`; the `versions`,
|
version.
|
||||||
`lastVersion` and `Runner Version` dropdown entries in `docusaurus.config.js` are
|
|
||||||
built from it, so a new series only has to be cut with
|
|
||||||
`pnpm run docusaurus docs:version:runner-docs <series>`.
|
|
||||||
|
|
||||||
The pages under `reference/` are generated from the runner sources — the command
|
The pages under `reference/` are generated from the runner sources — the command
|
||||||
line reference from `--help`, the example configuration from `generate-config`:
|
line reference from `--help`, the example configuration from `generate-config`:
|
||||||
@@ -70,12 +104,18 @@ make update-runner-docs-released # every series, from its newest stable tag
|
|||||||
./update_runner_docs.sh v3.0.2 runner-docs_versioned_docs/version-3/reference
|
./update_runner_docs.sh v3.0.2 runner-docs_versioned_docs/version-3/reference
|
||||||
```
|
```
|
||||||
|
|
||||||
`--released` (what `make update-runner-docs-released` runs) needs no version list:
|
These are refreshed automatically as well: the `update runner reference`
|
||||||
it regenerates every `runner-docs_versioned_docs/version-<series>/reference`
|
workflow runs weekly and opens a pull request whenever the runner's CLI or
|
||||||
directory from the newest stable `v<series>.x.y` tag of `gitea/runner`, looked up
|
example configuration changed. Generating them needs Go, since the script builds
|
||||||
through the Gitea API.
|
the runner binary.
|
||||||
|
|
||||||
All of these pages are refreshed automatically: the `update runner reference`
|
## Deployment
|
||||||
workflow runs weekly, regenerates the develop and the released references, and
|
|
||||||
opens a pull request whenever the runner's CLI or example configuration changed.
|
`main` is built and published by the `Build and Publish Docs site` workflow, to
|
||||||
Generating them needs Go, since the script builds the runner binary.
|
S3/CloudFront and to Cloudflare Pages. `cloudflare/_headers` sets the cache
|
||||||
|
policy and `cloudflare/_redirects` keeps the urls the site used to serve; both
|
||||||
|
are copied next to the build output by that workflow.
|
||||||
|
`cloudflare/docsearch-crawler.json` is the Algolia crawler configuration.
|
||||||
|
|
||||||
|
`/enterprise/` is served by another deployment and routed to it by a Cloudflare
|
||||||
|
worker that lives outside this repository.
|
||||||
+15
-36
@@ -1,45 +1,24 @@
|
|||||||
# Cloudflare Pages reads this file from the root of the deployed directory
|
# Cloudflare Pages reads this file from the root of the deployed directory. It
|
||||||
# (build/_headers once .gitea/workflows/build-and-publish.yaml copies it
|
# is copied into the build output by the publish workflow, so that it is not
|
||||||
# in). It lives here, outside static/, so that `make clean` (which does
|
# uploaded to S3/CloudFront where it would be publicly readable at /_headers.
|
||||||
# `rm -rf static/_*`) cannot delete it and so Docusaurus does not copy it
|
|
||||||
# into every locale build directory (see the locale rules below for why
|
|
||||||
# that would matter anyway).
|
|
||||||
|
|
||||||
# Docusaurus content-hashed build output (webpack chunks, css, etc). The
|
|
||||||
# filename changes whenever the content changes, so it is safe to cache
|
|
||||||
# these for a long time and mark them immutable.
|
|
||||||
/assets/*
|
|
||||||
Cache-Control: public, max-age=31536000, immutable
|
|
||||||
|
|
||||||
# docusaurus.config.js configures locales ["en-us", "zh-cn", "zh-tw"] with
|
|
||||||
# "en-us" as the default. Docusaurus only serves the default locale at the
|
|
||||||
# site root; every other locale gets a full copy of the build under a
|
|
||||||
# /<locale>/ prefix (e.g. build/zh-cn, build/zh-tw), including its own
|
|
||||||
# assets/img/images directories. Confirmed live: /zh-cn/ references
|
|
||||||
# /zh-cn/assets/css/styles.a0cbb0c4.css, and without a rule matching that
|
|
||||||
# path it is served with `cache-control: public, max-age=0,
|
|
||||||
# must-revalidate` instead of the caching below.
|
|
||||||
#
|
#
|
||||||
# Use a `:locale` placeholder rather than listing "zh-cn" and "zh-tw"
|
# Unlike the docusaurus build, astro emits a single content hashed asset
|
||||||
# explicitly, so this keeps working if a locale is added or removed later
|
# directory shared by every language, so no per locale rules are needed.
|
||||||
# without anyone remembering to update this file. Cloudflare Pages only
|
|
||||||
# allows a single `*` splat per path, so the locale segment has to be a
|
# Content hashed build output (js, css, fonts). The file name changes with the
|
||||||
# named placeholder (`:locale`, matching exactly one path segment) rather
|
# content, so these can be cached forever.
|
||||||
# than a second splat; `/*/assets/*` is not a valid pattern.
|
/_astro/*
|
||||||
/:locale/assets/*
|
|
||||||
Cache-Control: public, max-age=31536000, immutable
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|
||||||
# Files under static/img and static/images keep stable filenames and can be
|
# Files under public/img and public/images keep stable names and are replaced in
|
||||||
# replaced in place (same name, new content), so use a short, conservative
|
# place, so they get a short, conservative max-age instead.
|
||||||
# max-age instead of immutable caching.
|
|
||||||
/img/*
|
/img/*
|
||||||
Cache-Control: public, max-age=86400
|
Cache-Control: public, max-age=86400
|
||||||
|
|
||||||
/:locale/img/*
|
|
||||||
Cache-Control: public, max-age=86400
|
|
||||||
|
|
||||||
/images/*
|
/images/*
|
||||||
Cache-Control: public, max-age=86400
|
Cache-Control: public, max-age=86400
|
||||||
|
|
||||||
/:locale/images/*
|
# The pagefind index, only present when the site is built without algolia
|
||||||
Cache-Control: public, max-age=86400
|
# docsearch. Content hashed as well.
|
||||||
|
/pagefind/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"outdated.message": {
|
|
||||||
"message": "当前中文文档翻译不是最新版,访问英文版本查看最新内容,或"
|
|
||||||
},
|
|
||||||
"outdated.help": {
|
|
||||||
"message": "帮助我们翻译"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -57,4 +57,4 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
|
|||||||
|
|
||||||
## 合并请求模板
|
## 合并请求模板
|
||||||
|
|
||||||
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](usage/issue-pull-request-templates.md)
|
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](issue-pull-request-templates.md)
|
||||||
@@ -8,6 +8,8 @@ aliases:
|
|||||||
- /zh-cn/windows-service
|
- /zh-cn/windows-service
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# 注册为 Windows 服务
|
||||||
|
|
||||||
## 准备工作
|
## 准备工作
|
||||||
|
|
||||||
在 C:\gitea\custom\conf\app.ini 中进行了以下更改:
|
在 C:\gitea\custom\conf\app.ini 中进行了以下更改:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ sidebar_position: 25
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 变量
|
# 变量
|
||||||
|
|
||||||
您可以创建用户、组织和仓库级别的变量。变量的级别取决于创建它的位置。当创建变量时,变量的名称会被
|
您可以创建用户、组织和仓库级别的变量。变量的级别取决于创建它的位置。当创建变量时,变量的名称会被
|
||||||
转换为大写,在yaml文件中引用时需要使用大写。
|
转换为大写,在yaml文件中引用时需要使用大写。
|
||||||
|
|||||||
@@ -59,4 +59,4 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
|
|||||||
|
|
||||||
## 合并请求模板
|
## 合并请求模板
|
||||||
|
|
||||||
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](usage/issue-pull-request-templates.md)
|
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](issue-pull-request-templates.md)
|
||||||
@@ -57,4 +57,4 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
|
|||||||
|
|
||||||
## 合并请求模板
|
## 合并请求模板
|
||||||
|
|
||||||
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](usage/issue-pull-request-templates.md)
|
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](issue-pull-request-templates.md)
|
||||||
@@ -57,4 +57,4 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
|
|||||||
|
|
||||||
## 合并请求模板
|
## 合并请求模板
|
||||||
|
|
||||||
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](usage/issue-pull-request-templates.md)
|
有关合并请求模板的更多信息请您移步 : [工单与合并请求模板](issue-pull-request-templates.md)
|
||||||
+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
|
- /zh-tw/windows-service
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# 註冊為 Windows 服務
|
||||||
|
|
||||||
## 準備工作
|
## 準備工作
|
||||||
|
|
||||||
在 C:\gitea\custom\conf\app.ini 中進行了以下更改:
|
在 C:\gitea\custom\conf\app.ini 中進行了以下更改:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ sidebar_position: 25
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 變量
|
# 變量
|
||||||
|
|
||||||
您可以建立使用者、組織和儲存庫級別的變量。變量的級別取決於建立它的位置。當建立變量時,變量的名稱會被
|
您可以建立使用者、組織和儲存庫級別的變量。變量的級別取決於建立它的位置。當建立變量時,變量的名稱會被
|
||||||
轉換為大寫,在yaml文件中引用時需要使用大寫。
|
轉換為大寫,在yaml文件中引用時需要使用大寫。
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"link.title.Docs": {
|
|
||||||
"message": "文件",
|
|
||||||
"description": "The title of the footer links column with title=Docs in the footer"
|
|
||||||
},
|
|
||||||
"link.title.Community": {
|
|
||||||
"message": "社區",
|
|
||||||
"description": "The title of the footer links column with title=Community in the footer"
|
|
||||||
},
|
|
||||||
"link.title.More": {
|
|
||||||
"message": "更多",
|
|
||||||
"description": "The title of the footer links column with title=More in the footer"
|
|
||||||
},
|
|
||||||
"link.item.label.Tutorial": {
|
|
||||||
"message": "教學",
|
|
||||||
"description": "The label of footer link with label=Tutorial linking to /"
|
|
||||||
},
|
|
||||||
"link.item.label.Code": {
|
|
||||||
"message": "開源程式碼",
|
|
||||||
"description": "The label of footer link with label=Code linking to https://github.com/go-gitea/gitea"
|
|
||||||
},
|
|
||||||
"link.item.label.Stack Overflow": {
|
|
||||||
"message": "Stack Overflow",
|
|
||||||
"description": "The label of footer link with label=Stack Overflow linking to https://stackoverflow.com/questions/tagged/gitea"
|
|
||||||
},
|
|
||||||
"link.item.label.Discord": {
|
|
||||||
"message": "Discord",
|
|
||||||
"description": "The label of footer link with label=Discord linking to https://discord.gg/gitea"
|
|
||||||
},
|
|
||||||
"link.item.label.Twitter": {
|
|
||||||
"message": "Twitter",
|
|
||||||
"description": "The label of footer link with label=Twitter linking to https://twitter.com/giteaio"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"title": {
|
|
||||||
"message": "Gitea"
|
|
||||||
},
|
|
||||||
"item.label.Docs": {
|
|
||||||
"message": "文件"
|
|
||||||
},
|
|
||||||
"item.label.Code": {
|
|
||||||
"message": "開源程式碼"
|
|
||||||
},
|
|
||||||
"item.label.Support": {
|
|
||||||
"message": "支援"
|
|
||||||
},
|
|
||||||
"item.label.Blog": {
|
|
||||||
"message": "部落格"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-45
@@ -3,53 +3,15 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"docusaurus": "docusaurus",
|
"dev": "pnpm --filter @gitea-docs/site dev",
|
||||||
"start": "docusaurus start",
|
"dev:en-latest": "pnpm --filter @gitea-docs/site dev:en-latest",
|
||||||
"start-CSRApi": "cross-env API_SSR='false' docusaurus start",
|
"build": "pnpm --filter @gitea-docs/site build",
|
||||||
"build": "docusaurus build",
|
"preview": "pnpm --filter @gitea-docs/site preview",
|
||||||
"build-CSRApi": "cross-env API_SSR='false' docusaurus build",
|
"check": "pnpm --filter @gitea-docs/site check",
|
||||||
"swizzle": "docusaurus swizzle",
|
"cut-version": "node scripts/cut-version.mjs"
|
||||||
"deploy": "docusaurus deploy",
|
|
||||||
"clear": "docusaurus clear",
|
|
||||||
"serve": "docusaurus serve",
|
|
||||||
"write-translations": "docusaurus write-translations",
|
|
||||||
"write-heading-ids": "docusaurus write-heading-ids"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@docusaurus/core": "3.10.2",
|
|
||||||
"@docusaurus/faster": "3.10.2",
|
|
||||||
"@docusaurus/plugin-content-docs": "3.10.2",
|
|
||||||
"@docusaurus/preset-classic": "3.10.2",
|
|
||||||
"@easyops-cn/docusaurus-search-local": "0.55.3",
|
|
||||||
"@emotion/react": "11.14.0",
|
|
||||||
"@emotion/styled": "11.14.1",
|
|
||||||
"@mdx-js/react": "3.1.1",
|
|
||||||
"@mui/material": "9.2.0",
|
|
||||||
"clsx": "2.1.1",
|
|
||||||
"docusaurus-plugin-plausible": "0.0.5",
|
|
||||||
"prism-react-renderer": "2.4.1",
|
|
||||||
"react": "19.2.8",
|
|
||||||
"react-dom": "19.2.8",
|
|
||||||
"redocusaurus": "2.5.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@docusaurus/module-type-aliases": "3.10.2",
|
|
||||||
"cross-env": "10.1.0"
|
|
||||||
},
|
|
||||||
"browserslist": {
|
|
||||||
"production": [
|
|
||||||
">0.5%",
|
|
||||||
"not dead",
|
|
||||||
"not op_mini all"
|
|
||||||
],
|
|
||||||
"development": [
|
|
||||||
"last 1 chrome version",
|
|
||||||
"last 1 firefox version",
|
|
||||||
"last 1 safari version"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"packageManager": "[email protected]"
|
"packageManager": "[email protected]"
|
||||||
}
|
}
|
||||||
@@ -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
+3137
-11721
File diff suppressed because it is too large.
Load diff
+6
-2
@@ -1,8 +1,12 @@
|
|||||||
|
packages:
|
||||||
|
- sites/*
|
||||||
|
- packages/*
|
||||||
|
|
||||||
savePrefix: ''
|
savePrefix: ''
|
||||||
dedupePeerDependents: false
|
dedupePeerDependents: false
|
||||||
updateNotifier: false
|
updateNotifier: false
|
||||||
minimumReleaseAge: 0
|
minimumReleaseAge: 0
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@swc/core': true
|
esbuild: true
|
||||||
core-js: false
|
sharp: true
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
Loaded 50 of 99 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user