mirror of
https://gitea.com/gitea/docs.git
synced 2026-09-20 04:58:52 +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:
@@ -0,0 +1,120 @@
|
||||
# Astro + Starlight site
|
||||
|
||||
Renders the gitea documentation with [Starlight](https://starlight.astro.build):
|
||||
the docs (6 versions x 3 languages), the runner docs (4 series) and the api
|
||||
reference (7 swagger documents). The enterprise docs stay in their own
|
||||
deployment and are linked from the header.
|
||||
|
||||
The content directories are the ones the site has always used — `docs/`,
|
||||
`versioned_docs/`, `i18n/`, `runner-docs/`, `runner-docs_versioned_docs/` and
|
||||
`static/swagger-*.json` — so the release scripts and the translation workflow
|
||||
stay unchanged.
|
||||
|
||||
## Running it
|
||||
|
||||
Everything is driven from the repository root, see the `README.md` there:
|
||||
|
||||
```shell
|
||||
make serve-fast # english, the version served at the root, starts in seconds
|
||||
make serve # whole matrix, api included
|
||||
make serve-built # build and serve it, the only way to try the search locally
|
||||
make build
|
||||
make check
|
||||
```
|
||||
|
||||
`GITEA_DOCS_PRODUCTS`, `GITEA_DOCS_VERSIONS` and `GITEA_DOCS_LOCALES` (comma
|
||||
separated) restrict the matrix, which is what `make serve-fast` uses.
|
||||
|
||||
## How it is put together
|
||||
|
||||
`packages/content-loader` holds the product matrix and the astro content loader.
|
||||
`src/products.ts` is the single source of truth for product x version x language
|
||||
and drives everything else: content loading, sidebars, the version and language
|
||||
pickers, the version banner, the search facets and the api schemas.
|
||||
|
||||
| Source | Route |
|
||||
| --- | --- |
|
||||
| `versioned_docs/version-1.27/` | `/` |
|
||||
| `docs/` | `/next/` |
|
||||
| `versioned_docs/version-1.26/` | `/1.26/` |
|
||||
| `i18n/zh-cn/docusaurus-plugin-content-docs/version-1.27/` | `/zh-cn/` |
|
||||
| `runner-docs_versioned_docs/version-3/` | `/runner/` |
|
||||
| `runner-docs/` | `/runner/develop/` |
|
||||
| `static/swagger-27.json` | `/api/` |
|
||||
| `static/swagger-latest.json` | `/api/next/` |
|
||||
|
||||
The loader reads the markdown in two passes. The first one works out the route
|
||||
of every file, the second normalizes and renders it:
|
||||
|
||||
- substitutes the version variables (`@version@`, `@dockerVersion@`, ...)
|
||||
- lifts the leading `# heading` into the starlight `title`
|
||||
- maps `sidebar_position` to `sidebar.order` and honours the docusaurus `slug`,
|
||||
including the translations that moved a page
|
||||
- rewrites the relative `*.md` links to urls, resolving them against the file
|
||||
and against the version root, and reports the ones that do not resolve
|
||||
- turns the docusaurus admonitions into starlight asides (`:::warning` to
|
||||
`:::caution`, `:::info` to `:::note`, `:::note Title` to `:::note[Title]`)
|
||||
- recreates the index pages of `_category_.json` (`/category/installation/`,
|
||||
`/usage/actions/`)
|
||||
|
||||
Sidebars are built per (product, version, language) from `_category_.json` and
|
||||
from the docusaurus sidebar files (`sidebars.js`, `versioned_sidebars/`,
|
||||
`runner-sidebars.js`, `runner-docs_versioned_sidebars/`), and selected in
|
||||
`src/routeData.ts`, since starlight only supports a single static sidebar.
|
||||
|
||||
Set `GITEA_DOCS_STRICT_LINKS=true` to fail the build on a broken internal link
|
||||
instead of warning.
|
||||
|
||||
## Products and languages
|
||||
|
||||
The docs are the site itself, so their language comes first: `/zh-cn/1.26/`.
|
||||
The runner and the api are separate products and keep their language inside the
|
||||
product, `/runner/zh-cn/3/`, which is reserved: both are published in english
|
||||
only today. Starlight builds a fallback page in every configured language for
|
||||
every english page, so `/zh-cn/runner/` would exist; the post build integration
|
||||
removes those directories and `cloudflare/_redirects` sends them to the
|
||||
english page.
|
||||
|
||||
The version served at the root of a product has no number in its url: docs 1.27
|
||||
is `/`, runner 3 is `/runner/` and api 1.27 is `/api/`. `/1.27/`, `/runner/3/`
|
||||
and `/api/1.27/` are redirected onto them in `cloudflare/_redirects`.
|
||||
|
||||
## Api reference
|
||||
|
||||
`starlight-openapi` generates a page per operation from the seven swagger
|
||||
documents `update_api_docs.sh` maintains. Two adjustments happen at build time:
|
||||
|
||||
- the documents set `basePath` to the full `https://gitea.com/api/v1` url, which
|
||||
redoc accepted but is not valid swagger 2.0. A normalized copy is written to
|
||||
`.cache/openapi/` instead of touching the sources.
|
||||
- the plugin slugifies the base path, so `/api/1.26/` is generated as
|
||||
`/api/126/`. `src/integrations/postbuild.ts` renames the directories and
|
||||
rewrites the links once the build is done.
|
||||
|
||||
## Search
|
||||
|
||||
Algolia docsearch, enabled when `PUBLIC_DOCSEARCH_APP_ID` and
|
||||
`PUBLIC_DOCSEARCH_API_KEY` are set, pagefind otherwise, so a fork without
|
||||
credentials still gets search. Every page carries `docsearch:product`,
|
||||
`docsearch:version` and `docsearch:language` meta tags; the crawler turns them
|
||||
into facets and `src/config/docsearch.ts` filters the search on the product,
|
||||
version and language being read. `cloudflare/docsearch-crawler.json` holds the
|
||||
crawler configuration and only indexes the versions people read.
|
||||
|
||||
## Deployment
|
||||
|
||||
`cloudflare/_headers` and `cloudflare/_redirects` are copied next to the build
|
||||
output by the publish workflow. `/enterprise/*` is routed to the enterprise
|
||||
pages project by a Cloudflare worker maintained outside this repository.
|
||||
|
||||
## Differences between `pnpm dev` and the deployed site
|
||||
|
||||
- `/api/1.26/` is served at `/api/126/`, the renaming happens after the build.
|
||||
- `/zh-cn/runner/` still answers, the fallback pages are removed after the build.
|
||||
- `/1.27/`, `/runner/3/` and `/api/1.27/` are cloudflare redirects, so they only
|
||||
work on the deployed site.
|
||||
- search is built by pagefind at build time and only answers on the built site,
|
||||
unless the algolia docsearch credentials are set; `make serve-built` builds and
|
||||
serves it locally.
|
||||
- a scoped run only builds part of the matrix, so the products and versions left
|
||||
out answer with the 404 page; `pnpm dev` serves everything.
|
||||
@@ -0,0 +1,141 @@
|
||||
// @ts-check
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlightDocSearch from '@astrojs/starlight-docsearch';
|
||||
import starlightOpenAPI, { openAPISidebarGroups } from 'starlight-openapi';
|
||||
import { apiSchemas } from './src/config/api.js';
|
||||
import { analytics, announcement, announcementStorageKey } from './src/config/site.js';
|
||||
import { giteaApiSidebar } from './src/integrations/api.js';
|
||||
import { giteaPostBuild } from './src/integrations/postbuild.js';
|
||||
|
||||
const repoRoot = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../..');
|
||||
|
||||
// algolia docsearch replaces the local search once the credentials are set; the
|
||||
// build falls back to pagefind so that a fork without them still gets search
|
||||
const useDocSearch = Boolean(
|
||||
process.env.PUBLIC_DOCSEARCH_APP_ID && process.env.PUBLIC_DOCSEARCH_API_KEY,
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://docs.gitea.com',
|
||||
trailingSlash: 'always',
|
||||
// images, logos and the swagger documents are served from `static/`
|
||||
publicDir: path.join(repoRoot, 'static'),
|
||||
// the markdown sources live outside this package, allow the dev server to read them
|
||||
vite: {
|
||||
server: { fs: { allow: [repoRoot] } },
|
||||
plugins: [
|
||||
{
|
||||
// The api overview of starlight-openapi lists every operation of the
|
||||
// document, which repeats the sidebar; swap that section for the tag
|
||||
// cards of src/components/ApiOverviewTags.astro. The plugin has no
|
||||
// option for it, so the import is redirected instead.
|
||||
name: 'gitea-openapi-overview',
|
||||
enforce: 'pre',
|
||||
resolveId(source, importer) {
|
||||
if (
|
||||
source === './OverviewNavigationLinks.astro' &&
|
||||
importer?.includes('starlight-openapi/components/overview/OverviewSchema.astro')
|
||||
) {
|
||||
return path.join(repoRoot, 'sites/docs/src/components/ApiOverviewTags.astro');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// languages the sources tag code blocks with that shiki does not know
|
||||
markdown: {
|
||||
shikiConfig: {
|
||||
langAlias: {
|
||||
apacheconf: 'apache',
|
||||
conf: 'ini',
|
||||
curl: 'bash',
|
||||
gitignore: 'text',
|
||||
none: 'text',
|
||||
plantuml: 'text',
|
||||
tmpl: 'handlebars',
|
||||
},
|
||||
},
|
||||
},
|
||||
integrations: [
|
||||
giteaPostBuild(),
|
||||
starlight({
|
||||
title: 'Gitea Documentation',
|
||||
description: 'Git with a cup of tea',
|
||||
favicon: '/img/favicon.png',
|
||||
logo: { src: './src/assets/gitea.svg', alt: 'Gitea', replacesTitle: true },
|
||||
defaultLocale: 'root',
|
||||
locales: {
|
||||
root: { label: 'English', lang: 'en-US' },
|
||||
'zh-cn': { label: '简体中文', lang: 'zh-CN' },
|
||||
'zh-tw': { label: '繁體中文', lang: 'zh-TW' },
|
||||
},
|
||||
plugins: [
|
||||
starlightOpenAPI(apiSchemas),
|
||||
// after starlight-openapi, so it sees the groups the plugin built
|
||||
giteaApiSidebar(),
|
||||
...(useDocSearch
|
||||
? [starlightDocSearch({ clientOptionsModule: './src/config/docsearch.ts' })]
|
||||
: []),
|
||||
],
|
||||
// one sidebar per product, version and language, selected in the route
|
||||
// middleware; starlight only supports a single static one
|
||||
customCss: ['./src/styles/theme.css', './src/styles/custom.css'],
|
||||
components: {
|
||||
Header: './src/components/Header.astro',
|
||||
LanguageSelect: './src/components/LanguageSelect.astro',
|
||||
MarkdownContent: './src/components/MarkdownContent.astro',
|
||||
SiteTitle: './src/components/SiteTitle.astro',
|
||||
PageFrame: './src/components/PageFrame.astro',
|
||||
},
|
||||
head: [
|
||||
{
|
||||
// hide a dismissed announcement before the first paint, so the page
|
||||
// does not jump once the stylesheet and the scripts have loaded
|
||||
tag: 'script',
|
||||
content: `try{if(localStorage.getItem(${JSON.stringify(
|
||||
announcementStorageKey,
|
||||
)})===${JSON.stringify(
|
||||
announcement.id,
|
||||
)})document.documentElement.dataset.giteaAnnouncement='dismissed'}catch(e){}`,
|
||||
},
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: { async: true, src: `https://www.googletagmanager.com/gtag/js?id=${analytics.gtagId}` },
|
||||
},
|
||||
{
|
||||
tag: 'script',
|
||||
content: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${analytics.gtagId}');`,
|
||||
},
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: {
|
||||
defer: true,
|
||||
'data-domain': analytics.plausibleDomain,
|
||||
src: 'https://plausible.io/js/script.js',
|
||||
},
|
||||
},
|
||||
{ tag: 'meta', attrs: { property: 'og:logo', content: '/img/gitea.svg' } },
|
||||
{
|
||||
tag: 'meta',
|
||||
attrs: {
|
||||
name: 'keywords',
|
||||
content:
|
||||
'gitea, git, devops, actions, packages, documentation, self-hosted, open-source, version control, gitlab, github',
|
||||
},
|
||||
},
|
||||
],
|
||||
// the api groups are needed here so starlight-openapi pages get a sidebar,
|
||||
// the route middleware narrows it down to the version being read
|
||||
sidebar: [...openAPISidebarGroups],
|
||||
routeMiddleware: './src/routeData.ts',
|
||||
// the sources live outside src/content/docs, starlight only runs its
|
||||
// markdown transforms (asides, heading anchors) on files below these
|
||||
markdown: { processedDirs: ['../..'] },
|
||||
pagefind: !useDocSearch,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@gitea-docs/site",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:en-latest": "GITEA_DOCS_PRODUCTS=docs,runner GITEA_DOCS_VERSIONS=1.27,3 GITEA_DOCS_LOCALES=en-us astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/starlight": "0.41.7",
|
||||
"@astrojs/starlight-docsearch": "0.7.0",
|
||||
"@gitea-docs/content-loader": "workspace:*",
|
||||
"astro": "7.2.0",
|
||||
"sharp": "0.34.5",
|
||||
"starlight-openapi": "0.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.10",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="main_outline" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
|
||||
y="0px" viewBox="0 0 640 640" style="enable-background:new 0 0 640 640;" xml:space="preserve">
|
||||
<g>
|
||||
<path id="teabag" style="fill:#FFFFFF" d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8
|
||||
c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4
|
||||
c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"/>
|
||||
<g>
|
||||
<g>
|
||||
<path style="fill:#609926" d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2
|
||||
c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5
|
||||
c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5
|
||||
c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3
|
||||
c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1
|
||||
C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4
|
||||
c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7
|
||||
S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55
|
||||
c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8
|
||||
l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"/>
|
||||
<path style="fill:#609926" d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4
|
||||
c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1
|
||||
c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9
|
||||
c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3
|
||||
c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3
|
||||
c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29
|
||||
c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8
|
||||
C343.2,346.5,335,363.3,326.8,380.1z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,76 @@
|
||||
---
|
||||
import { Card, CardGrid } from '@astrojs/starlight/components';
|
||||
|
||||
/**
|
||||
* Replaces the "Operations" section of the api overview, which lists every
|
||||
* operation of the document and duplicates the sidebar one for one — 484 rows
|
||||
* on the gitea api. Each tag gets a card linking to its own page instead.
|
||||
*
|
||||
* Substituted for `starlight-openapi/components/overview/OverviewNavigationLinks.astro`
|
||||
* by the `gitea-openapi-overview` vite plugin in `astro.config.mjs`, so the
|
||||
* props are the ones that component receives.
|
||||
*/
|
||||
interface NavigationGroup {
|
||||
label: string;
|
||||
links: { href: string; label: string; method?: string; path?: string }[];
|
||||
operationTag?: { name: string; description?: string };
|
||||
type: 'operations' | 'webhooks';
|
||||
}
|
||||
|
||||
const { groups } = Astro.props as { groups: NavigationGroup[] };
|
||||
const visible = groups.filter((group) => group.links.length > 0);
|
||||
|
||||
/**
|
||||
* Page of a tag, `/api/operations/tags/repository/`. Derived from the first
|
||||
* operation link of the group, which is `<base>/operations/<operation>/`, so it
|
||||
* works for every version without knowing the base path here.
|
||||
*/
|
||||
function tagHref(group: NavigationGroup): string | undefined {
|
||||
if (group.type !== 'operations' || !group.operationTag) return undefined;
|
||||
const first = group.links[0]?.href;
|
||||
const base = first?.slice(0, first.indexOf('operations/'));
|
||||
if (!base) return undefined;
|
||||
const slug = group.operationTag.name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return `${base}operations/tags/${slug}/`;
|
||||
}
|
||||
|
||||
function count(group: NavigationGroup): string {
|
||||
const total = group.links.length;
|
||||
return `${total} ${total === 1 ? 'operation' : 'operations'}`;
|
||||
}
|
||||
---
|
||||
|
||||
{
|
||||
visible.length > 0 && (
|
||||
<div class="gitea-api-overview">
|
||||
<h2 id="operations">Operations</h2>
|
||||
<CardGrid>
|
||||
{visible.map((group) => {
|
||||
const href = tagHref(group);
|
||||
return (
|
||||
<Card title={group.label} icon="open-book">
|
||||
{group.operationTag?.description && <p>{group.operationTag.description}</p>}
|
||||
<p class="gitea-api-overview-count">
|
||||
{href ? <a href={href}>{count(group)}</a> : count(group)}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</CardGrid>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.gitea-api-overview {
|
||||
margin-top: 3.25rem;
|
||||
}
|
||||
|
||||
.gitea-api-overview-count {
|
||||
font-size: var(--sl-text-sm);
|
||||
color: var(--sl-color-gray-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
import Default from '@astrojs/starlight/components/Header.astro';
|
||||
import { t } from '../config/strings';
|
||||
import { announcement, announcementStorageKey } from '../config/site';
|
||||
|
||||
/**
|
||||
* The announcement strip belongs above everything, not above the content
|
||||
* column, so it is rendered inside the fixed header bar. `--sl-nav-height`
|
||||
* accounts for its height in `src/styles/custom.css`, which is what starlight
|
||||
* derives the offset of the sidebar, the content and the table of contents
|
||||
* from, so nothing ends up underneath it.
|
||||
*
|
||||
* Dismissing it is remembered per announcement id; the inline script in
|
||||
* `astro.config.mjs` applies that before the first paint, so the page does not
|
||||
* jump.
|
||||
*/
|
||||
const strings = t(Astro.locals.starlightRoute.entry.data.gitea?.locale ?? 'en-us');
|
||||
---
|
||||
|
||||
<div class="gitea-header">
|
||||
<gitea-announcement
|
||||
class="gitea-announcement"
|
||||
data-id={announcement.id}
|
||||
data-storage-key={announcementStorageKey}
|
||||
data-pagefind-ignore
|
||||
>
|
||||
<a href={announcement.href}>{announcement.text}</a>
|
||||
<button type="button" aria-label={strings.dismissAnnouncement} title={strings.dismissAnnouncement}>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path
|
||||
d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</gitea-announcement>
|
||||
<div class="gitea-header-main">
|
||||
<Default />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class GiteaAnnouncement extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.querySelector('button')?.addEventListener('click', () => {
|
||||
const { id, storageKey } = this.dataset;
|
||||
document.documentElement.dataset.giteaAnnouncement = 'dismissed';
|
||||
try {
|
||||
if (id && storageKey) localStorage.setItem(storageKey, id);
|
||||
} catch {
|
||||
// storage can be unavailable, the strip stays dismissed for this page
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
customElements.define('gitea-announcement', GiteaAnnouncement);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.gitea-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.gitea-announcement {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--gitea-announcement-height);
|
||||
padding-inline: 1rem;
|
||||
background-color: var(--sl-color-accent);
|
||||
color: var(--sl-color-text-invert);
|
||||
font-size: var(--sl-text-xs);
|
||||
}
|
||||
|
||||
.gitea-announcement a {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.gitea-announcement a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.gitea-announcement button {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
margin-inline-start: 0.5rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0.25rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.gitea-announcement button:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* the navigation row carries the horizontal padding of the header bar, and
|
||||
reserves room for the mobile menu button where starlight shows one */
|
||||
.gitea-header-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-inline: var(--sl-nav-pad-x);
|
||||
}
|
||||
|
||||
@media (max-width: 49.999rem) {
|
||||
:global([data-has-sidebar]) .gitea-header-main {
|
||||
padding-inline-end: calc(
|
||||
var(--sl-nav-gap) + var(--sl-nav-pad-x) + var(--sl-menu-button-size)
|
||||
);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
import Select from '@astrojs/starlight/components/Select.astro';
|
||||
import type { LocaleId } from '@gitea-docs/content-loader';
|
||||
import { switchHref } from '../lib/routes';
|
||||
import { t } from '../config/strings';
|
||||
import { languagePicker, versionPicker } from '../lib/versions';
|
||||
|
||||
/**
|
||||
* Version and language pickers. Both live in the language select slot because
|
||||
* that is the only header slot starlight exposes for them; the version list
|
||||
* comes from the product matrix and the language list only offers the languages
|
||||
* the current product is published in, so `/api/` never offers chinese.
|
||||
*/
|
||||
const meta = Astro.locals.starlightRoute.entry.data.gitea;
|
||||
const pathname = Astro.url.pathname;
|
||||
const strings = t(meta?.locale ?? 'en-us');
|
||||
|
||||
const versions = meta
|
||||
? versionPicker(meta).map((item) => ({
|
||||
label: item.label,
|
||||
value: switchHref(pathname, meta, { version: item.id }),
|
||||
selected: item.current,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const languages = meta
|
||||
? languagePicker(meta).map((item) => ({
|
||||
label: item.label,
|
||||
value: switchHref(pathname, meta, { locale: item.id as LocaleId }),
|
||||
selected: item.current,
|
||||
}))
|
||||
: [];
|
||||
---
|
||||
|
||||
<gitea-picker>
|
||||
{versions.length > 1 && <Select icon="down-caret" label={strings.versionLabel} options={versions} width="8em" />}
|
||||
{languages.length > 1 && <Select icon="translate" label={strings.languageLabel} options={languages} width="7em" />}
|
||||
</gitea-picker>
|
||||
<a class="gitea-sign-in" href="https://gitea.com/user/login">{strings.signIn}</a>
|
||||
|
||||
<script>
|
||||
class GiteaPicker extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
for (const select of this.querySelectorAll('select')) {
|
||||
select.addEventListener('change', (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (target instanceof HTMLSelectElement) window.location.href = target.value;
|
||||
});
|
||||
|
||||
// A page restored from the back/forward cache keeps the selection the
|
||||
// user made before navigating away, which then no longer matches the
|
||||
// page being shown. Put it back on the option the server marked.
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (!event.persisted) return;
|
||||
const marked = select.querySelector('option[selected]');
|
||||
const index = marked instanceof HTMLOptionElement ? marked.index : 0;
|
||||
if (select.selectedIndex !== index) select.selectedIndex = index;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
customElements.define('gitea-picker', GiteaPicker);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
gitea-picker {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import Default from '@astrojs/starlight/components/MarkdownContent.astro';
|
||||
import { t } from '../config/strings';
|
||||
|
||||
/**
|
||||
* Translated pages carry the notice the docusaurus site showed: a translation
|
||||
* can lag behind the english original, so point at it and at the translation
|
||||
* guide. Fallback pages already get starlight's own notice.
|
||||
*/
|
||||
const route = Astro.locals.starlightRoute;
|
||||
const meta = route.entry.data.gitea;
|
||||
const translated = Boolean(meta) && meta!.locale !== 'en-us' && !route.isFallback;
|
||||
const editUrl = route.editUrl?.href;
|
||||
const strings = t(meta?.locale ?? 'en-us');
|
||||
---
|
||||
|
||||
{
|
||||
translated && (
|
||||
<div class="gitea-translation-notice" data-pagefind-ignore>
|
||||
{strings.translationNotice}
|
||||
{editUrl && <a href={editUrl}>{strings.translationHelp}</a>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Default><slot /></Default>
|
||||
|
||||
<style>
|
||||
.gitea-translation-notice {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-inline-start: 3px solid var(--sl-color-orange);
|
||||
background-color: var(--sl-color-gray-6);
|
||||
font-size: var(--sl-text-sm);
|
||||
}
|
||||
.gitea-translation-notice a {
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
import Default from '@astrojs/starlight/components/PageFrame.astro';
|
||||
import SiteFooter from './SiteFooter.astro';
|
||||
|
||||
/**
|
||||
* The gitea site footer closes the page, below the sidebar as well as the
|
||||
* content and the table of contents. It is rendered outside the starlight page
|
||||
* frame, which is a `min-height: 100vh` column, so the footer is only reached
|
||||
* at the very bottom of the document.
|
||||
*/
|
||||
---
|
||||
|
||||
<Default>
|
||||
<Fragment slot="header"><slot name="header" /></Fragment>
|
||||
<Fragment slot="sidebar"><slot name="sidebar" /></Fragment>
|
||||
<slot />
|
||||
</Default>
|
||||
<SiteFooter />
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
import { t } from '../config/strings';
|
||||
import { footerLinks } from '../config/site';
|
||||
|
||||
/** The gitea site footer, rendered below every part of the page. */
|
||||
const strings = t(Astro.locals.starlightRoute.entry.data.gitea?.locale ?? 'en-us');
|
||||
---
|
||||
|
||||
<footer class="gitea-footer" data-pagefind-ignore>
|
||||
<div class="columns">
|
||||
{
|
||||
footerLinks.map((column) => (
|
||||
<div>
|
||||
<p class="title">{strings.footer[column.title] ?? column.title}</p>
|
||||
<ul>
|
||||
{column.items.map((item) => (
|
||||
<li>
|
||||
<a href={item.href}>{item.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.gitea-footer {
|
||||
padding: 2rem var(--sl-content-pad-x) 2.5rem;
|
||||
border-top: 1px solid var(--sl-color-hairline);
|
||||
background-color: var(--sl-color-bg-sidebar);
|
||||
font-size: var(--sl-text-sm);
|
||||
}
|
||||
|
||||
/* On wide viewports the navigation sidebar is fixed and reaches the bottom of
|
||||
the viewport, so the footer is painted over it to close the whole page.
|
||||
Below that width the sidebar is an overlay menu and must stay on top. */
|
||||
@media (min-width: 50rem) {
|
||||
.gitea-footer {
|
||||
position: relative;
|
||||
z-index: calc(var(--sl-z-index-menu) + 1);
|
||||
}
|
||||
}
|
||||
/* the columns are centred as a block, with each list left aligned inside it */
|
||||
.columns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2.5rem 6rem;
|
||||
max-width: 60rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--sl-color-white);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.gitea-footer ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.gitea-footer li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.gitea-footer a {
|
||||
color: var(--sl-color-gray-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
.gitea-footer a:hover {
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
import Default from '@astrojs/starlight/components/SiteTitle.astro';
|
||||
import { t } from '../config/strings';
|
||||
import { productNav } from '../lib/versions';
|
||||
|
||||
/**
|
||||
* The starlight header has no navigation links, the gitea site needs the four
|
||||
* products next to the logo. Rendered next to the site title so the rest of the
|
||||
* header (search, theme, pickers) keeps working untouched.
|
||||
*/
|
||||
const meta = Astro.locals.starlightRoute.entry.data.gitea;
|
||||
const strings = t(meta?.locale ?? 'en-us');
|
||||
const products = productNav(meta);
|
||||
---
|
||||
|
||||
<div class="gitea-site-title">
|
||||
<Default><slot /></Default>
|
||||
<nav class="gitea-nav" aria-label="Products">
|
||||
{
|
||||
products.map((product) => (
|
||||
<a href={product.href} class:list={[{ current: product.current }]}>
|
||||
{strings.products[product.id] ?? product.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.gitea-site-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gitea-nav {
|
||||
display: none;
|
||||
gap: 0.75rem;
|
||||
font-size: var(--sl-text-sm);
|
||||
}
|
||||
.gitea-nav a {
|
||||
color: var(--sl-color-gray-2);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gitea-nav a:hover,
|
||||
.gitea-nav a.current {
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
.gitea-nav a.current {
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (min-width: 72rem) {
|
||||
.gitea-nav {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getProduct } from '@gitea-docs/content-loader';
|
||||
import { apiTagDescriptions } from './apiTags';
|
||||
import { repoRoot } from './paths';
|
||||
|
||||
const cacheDir = path.join(repoRoot, 'sites/docs/.cache/openapi');
|
||||
|
||||
/**
|
||||
* `listAdminWorkflowJobs` to `list-admin-workflow-jobs`. starlight-openapi
|
||||
* builds the url of an operation page by slugifying its operation id, and
|
||||
* slugifying a camel case identifier just drops the word boundaries, which
|
||||
* gives `/api/operations/listadminworkflowjobs/`. Acronyms are kept together,
|
||||
* so `getGPGKey` becomes `get-gpg-key` and `userGetOAuth2Application` becomes
|
||||
* `user-get-oauth2-application`.
|
||||
*/
|
||||
export function kebabCase(value: string): string {
|
||||
return value
|
||||
.replace(/([a-z\d])([A-Z])/g, '$1-$2')
|
||||
.replace(/([A-Z]{2,})([A-Z][a-z])/g, '$1-$2')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
interface Operation {
|
||||
operationId?: string;
|
||||
}
|
||||
|
||||
interface SwaggerDocument {
|
||||
basePath?: string;
|
||||
host?: string;
|
||||
schemes?: string[];
|
||||
tags?: { name: string; description?: string }[];
|
||||
paths?: Record<string, Record<string, Operation | unknown>>;
|
||||
}
|
||||
|
||||
const httpMethods = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch'];
|
||||
|
||||
/**
|
||||
* Hyphenates the operation ids of a document, which is what the urls of the
|
||||
* operation pages are built from.
|
||||
*
|
||||
* Gitea has operation ids that only differ in case, `userGetOauth2Application`
|
||||
* (the list) and `userGetOAuth2Application` (a single one). They already
|
||||
* collide today, since the plugin lowercases them, and one of the two pages
|
||||
* silently overwrites the other. A colliding id is suffixed with the last path
|
||||
* parameter, so the two become `user-get-oauth2-application` and
|
||||
* `user-get-oauth2-application-by-id`.
|
||||
*/
|
||||
function hyphenateOperationIds(document: SwaggerDocument): void {
|
||||
const used = new Set<string>();
|
||||
|
||||
for (const [pathname, pathItem] of Object.entries(document.paths ?? {})) {
|
||||
for (const [method, value] of Object.entries(pathItem)) {
|
||||
if (!httpMethods.includes(method)) continue;
|
||||
const operation = value as Operation;
|
||||
if (!operation?.operationId) continue;
|
||||
|
||||
const base = kebabCase(operation.operationId);
|
||||
let id = base;
|
||||
if (used.has(id)) {
|
||||
const parameter = [...pathname.matchAll(/\{([^}]+)\}/g)].at(-1)?.[1];
|
||||
if (parameter) id = `${base}-by-${kebabCase(parameter)}`;
|
||||
for (let suffix = 2; used.has(id); suffix += 1) id = `${base}-${suffix}`;
|
||||
}
|
||||
used.add(id);
|
||||
operation.operationId = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `update_api_docs.sh` rewrites `basePath` of the swagger documents to the full
|
||||
* `https://gitea.com/api/v1` url, which redoc accepted but is not valid swagger
|
||||
* 2.0: the host belongs into `host` and the scheme into `schemes`. Normalize a
|
||||
* copy instead of touching the sources, which the docusaurus site still reads.
|
||||
*/
|
||||
function normalizeSchema(source: string, name: string): string {
|
||||
const document = JSON.parse(readFileSync(source, 'utf-8')) as SwaggerDocument;
|
||||
|
||||
if (document.basePath && /^https?:\/\//.test(document.basePath)) {
|
||||
const url = new URL(document.basePath);
|
||||
document.host = url.host;
|
||||
document.schemes = [url.protocol.replace(':', '')];
|
||||
document.basePath = url.pathname;
|
||||
}
|
||||
|
||||
// the operation id is only used to build the url of the operation page, it is
|
||||
// not rendered anywhere, so hyphenating it only makes the urls readable
|
||||
hyphenateOperationIds(document);
|
||||
|
||||
describeTags(document);
|
||||
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
const target = path.join(cacheDir, name);
|
||||
writeFileSync(target, JSON.stringify(document));
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives every tag used by the document a description, so starlight-openapi
|
||||
* builds a landing page for it and the api overview can link to those instead
|
||||
* of listing every operation.
|
||||
*/
|
||||
function describeTags(document: SwaggerDocument): void {
|
||||
const used = new Set<string>();
|
||||
for (const pathItem of Object.values(document.paths ?? {})) {
|
||||
for (const value of Object.values(pathItem)) {
|
||||
for (const tag of (value as { tags?: string[] })?.tags ?? []) used.add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
const described = new Map(document.tags?.map((tag) => [tag.name, tag]) ?? []);
|
||||
for (const name of [...used].sort()) {
|
||||
const description = described.get(name)?.description ?? apiTagDescriptions[name];
|
||||
if (description) described.set(name, { name, description });
|
||||
}
|
||||
document.tags = [...described.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* One starlight-openapi schema per documented api version, each mounted at the
|
||||
* route of its version: `/api/` for the latest release, `/api/1.26/` and
|
||||
* `/api/next/` for the others.
|
||||
*/
|
||||
export const apiSchemas = getProduct('api')
|
||||
.versions.filter((version) => version.schema)
|
||||
.map((version) => ({
|
||||
base: ['api', version.path].filter(Boolean).join('/'),
|
||||
label: `API ${version.label}`,
|
||||
schema: normalizeSchema(path.join(repoRoot, version.schema!), `${version.id}.json`),
|
||||
// the schema group is lifted to the top level of the sidebar in
|
||||
// src/middleware/api.ts, so the tag groups below it start collapsed and
|
||||
// starlight opens the one holding the current operation
|
||||
sidebar: {
|
||||
collapsed: true,
|
||||
label: `API ${version.label}`,
|
||||
// http method badges, coloured per method in src/styles/custom.css
|
||||
operations: { badges: true },
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Descriptions of the operation tags of the gitea api.
|
||||
*
|
||||
* The swagger documents gitea generates carry no top level `tags` section, so
|
||||
* starlight-openapi treats every tag as "minimal": it builds no landing page
|
||||
* for it and the api overview can only list all operations at once. Adding the
|
||||
* descriptions here gives every tag a page of its own, which is what the
|
||||
* overview links to.
|
||||
*
|
||||
* Each line summarizes what the operations of that tag actually cover; edit
|
||||
* them here, they are the same for every documented version.
|
||||
*/
|
||||
export const apiTagDescriptions: Record<string, string> = {
|
||||
admin: 'Instance administration: users, organizations, runners, cron jobs and system hooks.',
|
||||
issue: 'Issues and pull request conversations: comments, labels, milestones, reactions and timelines.',
|
||||
miscellaneous: 'Markdown rendering, signing keys, server version and instance metadata.',
|
||||
notification: 'Notification threads and subscriptions of the authenticated user.',
|
||||
organization: 'Organizations and their teams, members, repositories and settings.',
|
||||
package: 'Package registries: listing, inspecting and deleting published packages.',
|
||||
repository:
|
||||
'Repositories and their contents, branches, tags, releases, pull requests, webhooks and actions.',
|
||||
settings: 'Instance settings exposed to clients: api, attachment, repository and ui limits.',
|
||||
user: 'The authenticated user and other users: keys, tokens, follows, stars and applications.',
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DocSearchClientOptions } from '@astrojs/starlight-docsearch';
|
||||
|
||||
/**
|
||||
* Algolia docsearch, scoped to what is being read. Every page carries the
|
||||
* `docsearch:product`, `docsearch:version` and `docsearch:language` meta tags
|
||||
* (see `src/lib/search.ts`), the crawler turns them into facets and the modal
|
||||
* filters on them, so searching the 1.26 chinese docs never returns a 1.22
|
||||
* english page.
|
||||
*
|
||||
* The credentials are the public, search only ones and are injected at build
|
||||
* time from `PUBLIC_DOCSEARCH_*`.
|
||||
*/
|
||||
function facet(name: string): string | undefined {
|
||||
return document.querySelector<HTMLMetaElement>(`meta[name="docsearch:${name}"]`)?.content;
|
||||
}
|
||||
|
||||
export default {
|
||||
appId: import.meta.env.PUBLIC_DOCSEARCH_APP_ID,
|
||||
apiKey: import.meta.env.PUBLIC_DOCSEARCH_API_KEY,
|
||||
indexName: import.meta.env.PUBLIC_DOCSEARCH_INDEX_NAME ?? 'gitea',
|
||||
searchParameters: {
|
||||
facetFilters: [
|
||||
...(facet('language') ? [`language:${facet('language')}`] : []),
|
||||
...(facet('product') ? [`product:${facet('product')}`] : []),
|
||||
...(facet('version') ? [`version:${facet('version')}`] : []),
|
||||
],
|
||||
},
|
||||
} satisfies DocSearchClientOptions;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Repository root, the directory holding `docs/`, `versioned_docs/` and the
|
||||
* other content trees. Looked up from the working directory instead of
|
||||
* `import.meta.url`, which points into the bundle once astro has built the
|
||||
* server chunks.
|
||||
*/
|
||||
function findRepoRoot(start: string): string {
|
||||
let current = path.resolve(start);
|
||||
for (let depth = 0; depth < 6; depth += 1) {
|
||||
if (existsSync(path.join(current, 'versions.json'))) return current;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
throw new Error(`unable to locate the repository root from ${start}`);
|
||||
}
|
||||
|
||||
export const repoRoot = findRepoRoot(process.cwd());
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Chrome of the site: announcement strip, footer columns and analytics. */
|
||||
|
||||
export const announcement = {
|
||||
/** Bump to show the strip again to everyone who dismissed the previous one. */
|
||||
id: 'gitea-cloud-1',
|
||||
href: 'https://about.gitea.com/products/cloud',
|
||||
text: 'Try Gitea Cloud ☁️ for 30 days → Accelerate your Development & Deploys!',
|
||||
};
|
||||
|
||||
/** Key the dismissed announcement is remembered under. */
|
||||
export const announcementStorageKey = 'gitea:announcement-dismissed';
|
||||
|
||||
export const footerLinks = [
|
||||
{
|
||||
title: 'Community',
|
||||
items: [
|
||||
{ label: 'Awesome Gitea', href: 'https://gitea.com/gitea/awesome-gitea' },
|
||||
{ label: 'Stack Overflow', href: 'https://stackoverflow.com/questions/tagged/gitea' },
|
||||
{ label: 'Discord', href: 'https://discord.gg/gitea' },
|
||||
{ label: 'Forum', href: 'https://forum.gitea.com/' },
|
||||
{ label: 'Twitter', href: 'https://twitter.com/giteaio' },
|
||||
{ label: 'Mastodon', href: 'https://social.gitea.io/@gitea' },
|
||||
{ label: 'Bluesky', href: 'https://bsky.app/profile/gitea.com' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Code',
|
||||
items: [
|
||||
{ label: 'GitHub', href: 'https://github.com/go-gitea/gitea' },
|
||||
{ label: 'Gitea', href: 'https://gitea.com/gitea' },
|
||||
{ label: 'Tea CLI', href: 'https://gitea.com/gitea/tea' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'More',
|
||||
items: [
|
||||
{ label: 'Blog', href: 'https://blog.gitea.com/' },
|
||||
{ label: 'Gitea Cloud', href: 'https://about.gitea.com/products/cloud' },
|
||||
{ label: 'Enterprise', href: 'https://about.gitea.com/products/gitea' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const analytics = {
|
||||
gtagId: 'G-KHM0KYT506',
|
||||
plausibleDomain: 'docs.gitea.com',
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* The few strings the gitea specific components add. Kept here instead of in a
|
||||
* starlight i18n collection because the content collection is filled by the
|
||||
* loader, which has no place for the ui translation files.
|
||||
*/
|
||||
type Strings = {
|
||||
versionLabel: string;
|
||||
languageLabel: string;
|
||||
/** Labels of the products in the header, keyed by product id. */
|
||||
products: Record<string, string>;
|
||||
/** Titles of the footer columns, keyed by the english title. */
|
||||
footer: Record<string, string>;
|
||||
signIn: string;
|
||||
translationNotice: string;
|
||||
translationHelp: string;
|
||||
dismissAnnouncement: string;
|
||||
unreleased: (latest: string) => string;
|
||||
outdated: (version: string, latest: string) => string;
|
||||
};
|
||||
|
||||
const en: Strings = {
|
||||
versionLabel: 'Version',
|
||||
languageLabel: 'Language',
|
||||
products: { docs: 'Docs', api: 'API', runner: 'Runner', enterprise: 'Enterprise' },
|
||||
footer: { Community: 'Community', Code: 'Code', More: 'More' },
|
||||
signIn: 'Sign In',
|
||||
translationNotice: 'This translation may be behind the english original.',
|
||||
translationHelp: 'Help us translate it',
|
||||
dismissAnnouncement: 'Dismiss this announcement',
|
||||
unreleased: (latest) =>
|
||||
`This is the documentation of the next version, still under development. <a href="${latest}">See the latest release</a>.`,
|
||||
outdated: (version, latest) =>
|
||||
`This is the documentation of ${version}, which is no longer the latest release. <a href="${latest}">See the latest release</a>.`,
|
||||
};
|
||||
|
||||
const strings: Record<string, Strings> = {
|
||||
'en-us': en,
|
||||
'zh-cn': {
|
||||
versionLabel: '版本',
|
||||
languageLabel: '语言',
|
||||
products: { docs: '文档', api: 'API', runner: 'Runner', enterprise: '企业版' },
|
||||
footer: { Community: '社区', Code: '开源代码', More: '更多' },
|
||||
signIn: '登录',
|
||||
translationNotice: '当前中文文档翻译不是最新版,访问英文版本查看最新内容,或',
|
||||
translationHelp: '帮助我们翻译',
|
||||
dismissAnnouncement: '关闭此提示',
|
||||
unreleased: (latest) =>
|
||||
`这是下一个版本的文档,仍在开发中。<a href="${latest}">查看最新发布版本</a>。`,
|
||||
outdated: (version, latest) =>
|
||||
`这是 ${version} 的文档,已不是最新发布版本。<a href="${latest}">查看最新发布版本</a>。`,
|
||||
},
|
||||
'zh-tw': {
|
||||
versionLabel: '版本',
|
||||
languageLabel: '語言',
|
||||
products: { docs: '文件', api: 'API', runner: 'Runner', enterprise: '企業版' },
|
||||
footer: { Community: '社區', Code: '開源程式碼', More: '更多' },
|
||||
signIn: '登入',
|
||||
translationNotice: '當前中文文檔翻譯不是最新版,訪問英文版本查看最新內容,或',
|
||||
translationHelp: '幫助我們翻譯',
|
||||
dismissAnnouncement: '關閉此提示',
|
||||
unreleased: (latest) =>
|
||||
`這是下一個版本的文檔,仍在開發中。<a href="${latest}">查看最新發布版本</a>。`,
|
||||
outdated: (version, latest) =>
|
||||
`這是 ${version} 的文檔,已不是最新發布版本。<a href="${latest}">查看最新發布版本</a>。`,
|
||||
},
|
||||
};
|
||||
|
||||
export function t(locale: string): Strings {
|
||||
return strings[locale] ?? en;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { giteaDocsLoader } from '@gitea-docs/content-loader';
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { repoRoot } from './config/paths';
|
||||
import { giteaDocsSchema } from './schema';
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({
|
||||
loader: giteaDocsLoader({ root: repoRoot }),
|
||||
schema: giteaDocsSchema(),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { StarlightPlugin } from '@astrojs/starlight/types';
|
||||
|
||||
/**
|
||||
* Registers the middleware that adjusts the generated api pages. It has to be a
|
||||
* plugin so it can ask for `order: 'post'`, which puts it after the
|
||||
* starlight-openapi middleware that builds the sidebar groups; list it after
|
||||
* `starlightOpenAPI()` in the plugin array.
|
||||
*/
|
||||
export function giteaApiSidebar(): StarlightPlugin {
|
||||
return {
|
||||
name: 'gitea-api',
|
||||
hooks: {
|
||||
'config:setup': ({ addRouteMiddleware }) => {
|
||||
addRouteMiddleware({ entrypoint: './src/middleware/api.ts', order: 'post' });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { AstroIntegration } from 'astro';
|
||||
import { defaultLocale, locales, products, type LocaleId } from '@gitea-docs/content-loader';
|
||||
|
||||
/**
|
||||
* Two things the generators cannot get right on their own:
|
||||
*
|
||||
* - starlight-openapi slugifies the base path of a schema, so `/api/1.26/` is
|
||||
* generated as `/api/126/`. The directories are renamed and the links inside
|
||||
* the build are rewritten.
|
||||
* - starlight builds a fallback page in every configured language for every
|
||||
* english page. That is what we want for the docs, but the runner and the api
|
||||
* are separate products with their own language list, so `/zh-cn/runner/` has
|
||||
* to go; `cloudflare/_redirects` sends it to the english page.
|
||||
*/
|
||||
export function giteaPostBuild(): AstroIntegration {
|
||||
return {
|
||||
name: 'gitea-post-build',
|
||||
hooks: {
|
||||
'astro:build:done': async ({ dir, logger }) => {
|
||||
const dist = fileURLToPath(dir);
|
||||
const renames = await renameApiVersions(dist);
|
||||
if (renames.length > 0) {
|
||||
await rewriteLinks(dist, renames);
|
||||
logger.info(
|
||||
`renamed the api version directories: ${renames
|
||||
.map(([from, to]) => `${from} -> ${to}`)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const pruned = await pruneForeignLocales(dist);
|
||||
if (pruned.length > 0) logger.info(`removed the fallback pages of ${pruned.join(', ')}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Same slugification github-slugger applies to a starlight-openapi base path. */
|
||||
function slugify(segment: string): string {
|
||||
return segment.toLowerCase().replace(/[^\w-]/g, '');
|
||||
}
|
||||
|
||||
/** `/api/126/` back to `/api/1.26/`, as the version picker and the old urls expect. */
|
||||
async function renameApiVersions(dist: string): Promise<[string, string][]> {
|
||||
const api = products.find((product) => product.id === 'api');
|
||||
if (!api) return [];
|
||||
|
||||
const renames: [string, string][] = [];
|
||||
for (const version of api.versions) {
|
||||
if (!version.path || slugify(version.path) === version.path) continue;
|
||||
try {
|
||||
await rename(
|
||||
path.join(dist, api.base, slugify(version.path)),
|
||||
path.join(dist, api.base, version.path),
|
||||
);
|
||||
renames.push([slugify(version.path), version.path]);
|
||||
} catch {
|
||||
// the version was not part of this build
|
||||
}
|
||||
}
|
||||
return renames;
|
||||
}
|
||||
|
||||
async function rewriteLinks(dist: string, renames: [string, string][]): Promise<void> {
|
||||
const api = products.find((product) => product.id === 'api')!;
|
||||
const replacements = renames.map(
|
||||
([from, to]) => [`/${api.base}/${from}/`, `/${api.base}/${to}/`] as const,
|
||||
);
|
||||
|
||||
for await (const file of walk(dist)) {
|
||||
if (!/\.(html|xml|js|json)$/.test(file)) continue;
|
||||
const contents = await readFile(file, 'utf-8');
|
||||
let updated = contents;
|
||||
for (const [from, to] of replacements) updated = updated.replaceAll(from, to);
|
||||
if (updated !== contents) await writeFile(file, updated);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the language directories of the products not published in them. */
|
||||
async function pruneForeignLocales(dist: string): Promise<string[]> {
|
||||
const removed: string[] = [];
|
||||
for (const product of products) {
|
||||
if (!product.base || product.externalBaseUrl) continue;
|
||||
for (const locale of Object.keys(locales) as LocaleId[]) {
|
||||
if (locale === defaultLocale || product.locales.includes(locale)) continue;
|
||||
try {
|
||||
await rm(path.join(dist, locale, product.base), { recursive: true });
|
||||
removed.push(`/${locale}/${product.base}/`);
|
||||
} catch {
|
||||
// nothing was built there
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
async function* walk(dir: string): AsyncGenerator<string> {
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(full);
|
||||
else yield full;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
defaultLocale,
|
||||
getProduct,
|
||||
routePrefix,
|
||||
type LocaleId,
|
||||
} from '@gitea-docs/content-loader';
|
||||
import { getCollection } from 'astro:content';
|
||||
import type { GiteaMeta } from '../schema';
|
||||
|
||||
const entries = await getCollection('docs');
|
||||
|
||||
/** Every route the site serves, used to keep the pickers from linking to a 404. */
|
||||
const hrefs = new Set(
|
||||
entries.map((entry) => {
|
||||
const id = entry.id;
|
||||
if (id === 'index' || id === '') return '/';
|
||||
return `/${id.endsWith('/index') ? id.slice(0, -'/index'.length) : id}/`;
|
||||
}),
|
||||
);
|
||||
|
||||
export function exists(href: string): boolean {
|
||||
return hrefs.has(href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same page in another version or language. Falls back to the root of the
|
||||
* target when the page does not exist there, which happens for a version that
|
||||
* did not document the feature yet or for a page a translation has moved.
|
||||
*/
|
||||
export function switchHref(
|
||||
pathname: string,
|
||||
meta: GiteaMeta,
|
||||
target: { version?: string; locale?: LocaleId },
|
||||
): string {
|
||||
const product = getProduct(meta.product);
|
||||
const version = product.versions.find(
|
||||
(candidate) => candidate.id === (target.version ?? meta.version),
|
||||
);
|
||||
if (!version) return '/';
|
||||
|
||||
const locale = target.locale ?? (meta.locale as LocaleId);
|
||||
const prefix = routePrefix(product, version, locale);
|
||||
const root = prefix ? `/${prefix}/` : '/';
|
||||
|
||||
const current = meta.prefix ? `/${meta.prefix}/` : '/';
|
||||
if (!pathname.startsWith(current)) return root;
|
||||
const rest = pathname.slice(current.length);
|
||||
const href = `${root}${rest}`;
|
||||
return exists(href) ? href : root;
|
||||
}
|
||||
|
||||
export { defaultLocale };
|
||||
@@ -0,0 +1,21 @@
|
||||
import { locales, type LocaleId } from '@gitea-docs/content-loader';
|
||||
import type { GiteaMeta } from '../schema';
|
||||
|
||||
/**
|
||||
* Search facets of a page. The docsearch crawler turns these into the
|
||||
* `product`, `version` and `language` facets the search modal filters on, so
|
||||
* a search stays inside the product, version and language being read.
|
||||
*/
|
||||
export function searchMetaTags(meta: GiteaMeta): {
|
||||
tag: 'meta';
|
||||
attrs: { name: string; content: string };
|
||||
}[] {
|
||||
return [
|
||||
{ tag: 'meta', attrs: { name: 'docsearch:product', content: meta.product } },
|
||||
{ tag: 'meta', attrs: { name: 'docsearch:version', content: meta.version } },
|
||||
{
|
||||
tag: 'meta',
|
||||
attrs: { name: 'docsearch:language', content: locales[meta.locale as LocaleId]?.lang ?? meta.locale },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildSidebars,
|
||||
products,
|
||||
type DocusaurusSidebarItem,
|
||||
type SidebarEntry,
|
||||
type SidebarPage,
|
||||
} from '@gitea-docs/content-loader';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { repoRoot } from '../config/paths';
|
||||
|
||||
/**
|
||||
* Sidebar files of the docusaurus site: the top level order of the docs and the
|
||||
* handwritten sidebars of the runner, one per version. Read as is, so cutting a
|
||||
* version keeps needing no change here.
|
||||
*/
|
||||
function docusaurusSidebars(): Map<string, DocusaurusSidebarItem[]> {
|
||||
const sidebars = new Map<string, DocusaurusSidebarItem[]>();
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const read = (file: string): Record<string, DocusaurusSidebarItem[]> | undefined => {
|
||||
const full = path.join(repoRoot, file);
|
||||
if (!existsSync(full)) return undefined;
|
||||
try {
|
||||
return file.endsWith('.json')
|
||||
? (JSON.parse(readFileSync(full, 'utf-8')) as Record<string, DocusaurusSidebarItem[]>)
|
||||
: (require(full) as Record<string, DocusaurusSidebarItem[]>);
|
||||
} catch (error) {
|
||||
// an unreadable sidebar would silently fall back to the directory tree,
|
||||
// which reorders the whole navigation without any other sign
|
||||
throw new Error(`unable to read the sidebar ${file}: ${(error as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const files: Record<string, string> = {};
|
||||
for (const product of products) {
|
||||
for (const version of product.versions) {
|
||||
if (product.id === 'docs') {
|
||||
files[`docs@${version.id}`] =
|
||||
version.id === 'next'
|
||||
? 'sidebars.js'
|
||||
: `versioned_sidebars/version-${version.id}-sidebars.json`;
|
||||
} else if (product.id === 'runner') {
|
||||
files[`runner@${version.id}`] =
|
||||
version.id === 'develop'
|
||||
? 'runner-sidebars.js'
|
||||
: `runner-docs_versioned_sidebars/version-${version.id}-sidebars.json`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, file] of Object.entries(files)) {
|
||||
const config = read(file);
|
||||
const items = config && (config.docs ?? config.runner ?? Object.values(config)[0]);
|
||||
if (items) sidebars.set(key, items);
|
||||
}
|
||||
return sidebars;
|
||||
}
|
||||
|
||||
const entries = await getCollection('docs');
|
||||
|
||||
const pages: SidebarPage[] = entries.flatMap((entry) => {
|
||||
const meta = entry.data.gitea;
|
||||
if (!meta) return [];
|
||||
const id = entry.id;
|
||||
const href =
|
||||
id === 'index' || id === ''
|
||||
? '/'
|
||||
: `/${id.endsWith('/index') ? id.slice(0, -'/index'.length) : id}/`;
|
||||
return [{ id, href, title: entry.data.title, hidden: entry.data.sidebar.hidden, meta }];
|
||||
});
|
||||
|
||||
/** Sidebars of every (product, version, language), keyed by their route prefix. */
|
||||
export const sidebars: Map<string, SidebarEntry[]> = buildSidebars(pages, docusaurusSidebars());
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
defaultLocale,
|
||||
getProduct,
|
||||
locales,
|
||||
products,
|
||||
routePrefix,
|
||||
type LocaleId,
|
||||
type ProductDef,
|
||||
type VersionDef,
|
||||
} from '@gitea-docs/content-loader';
|
||||
import type { GiteaMeta } from '../schema';
|
||||
import { t } from '../config/strings';
|
||||
|
||||
export interface PickerItem {
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
/** Url of the root of a (product, version, language). */
|
||||
export function versionHref(product: ProductDef, version: VersionDef, locale: LocaleId): string {
|
||||
if (product.externalBaseUrl) return product.externalBaseUrl;
|
||||
const prefix = routePrefix(product, version, locale);
|
||||
return prefix ? `/${prefix}/` : '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Version picker of the current page. A version is entered at the page with the
|
||||
* same route when it exists, at the root of the version otherwise; the check is
|
||||
* done client side because the target may live in another build chunk.
|
||||
*/
|
||||
export function versionPicker(meta: GiteaMeta): PickerItem[] {
|
||||
const product = getProduct(meta.product);
|
||||
return product.versions.map((version) => ({
|
||||
id: version.id,
|
||||
label: version.label,
|
||||
href: versionHref(product, version, meta.locale as LocaleId),
|
||||
current: version.id === meta.version,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Languages the product of the page is published in. */
|
||||
export function languagePicker(meta: GiteaMeta): PickerItem[] {
|
||||
const product = getProduct(meta.product);
|
||||
const version = product.versions.find((candidate) => candidate.id === meta.version);
|
||||
if (!version) return [];
|
||||
return product.locales.map((locale) => ({
|
||||
id: locale,
|
||||
label: locales[locale].label,
|
||||
href: versionHref(product, version, locale),
|
||||
current: locale === meta.locale,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Top level navigation, one entry per product. */
|
||||
export function productNav(meta: GiteaMeta | undefined): PickerItem[] {
|
||||
return products.map((product) => {
|
||||
const locale = (meta?.locale as LocaleId | undefined) ?? defaultLocale;
|
||||
const supported = product.locales.includes(locale) ? locale : defaultLocale;
|
||||
const version =
|
||||
product.versions.find((candidate) => candidate.latest) ?? product.versions[0];
|
||||
return {
|
||||
id: product.id,
|
||||
label: product.label,
|
||||
href: version ? versionHref(product, version, supported) : product.externalBaseUrl ?? '/',
|
||||
current: product.id === meta?.product,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Banner shown on every page of a development or an outdated version. */
|
||||
export function versionBanner(meta: GiteaMeta): string | undefined {
|
||||
const product = getProduct(meta.product);
|
||||
const version = product.versions.find((candidate) => candidate.id === meta.version);
|
||||
if (!version) return undefined;
|
||||
const latest = product.versions.find((candidate) => candidate.latest);
|
||||
const strings = t(meta.locale);
|
||||
const latestHref = latest ? versionHref(product, latest, meta.locale as LocaleId) : '/';
|
||||
if (version.banner === 'unreleased') return strings.unreleased(latestHref);
|
||||
if (latest && !version.latest && version.id !== 'develop') {
|
||||
return strings.outdated(version.label, latestHref);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { defineRouteMiddleware } from '@astrojs/starlight/route-data';
|
||||
import { getProduct } from '@gitea-docs/content-loader';
|
||||
|
||||
/**
|
||||
* Adjusts the pages starlight-openapi generates.
|
||||
*
|
||||
* The plugin replaces a placeholder group in the sidebar with one group per
|
||||
* schema, so an api page would list all seven documented versions. Keep the one
|
||||
* of the version being read and lift its entries to the top level: the version
|
||||
* is already picked with the version selector. It also titles both the schema
|
||||
* overview and every tag page "Overview", which makes the browser tab, the
|
||||
* breadcrumbs and the search results ambiguous.
|
||||
*
|
||||
* This runs after the plugin, which registers its own middleware with
|
||||
* `order: 'post'`; `src/routeData.ts` runs before it and must not touch the
|
||||
* placeholder, or there would be nothing left for the plugin to replace.
|
||||
*/
|
||||
export const onRequest = defineRouteMiddleware((context) => {
|
||||
const route = context.locals.starlightRoute;
|
||||
const meta = route.entry.data.gitea;
|
||||
if (!meta || meta.product !== 'api') return;
|
||||
|
||||
const version = getProduct('api').versions.find((candidate) => candidate.id === meta.version);
|
||||
if (!version) return;
|
||||
|
||||
const group = route.sidebar.find(
|
||||
(entry) => entry.type === 'group' && entry.label === `API ${version.label}`,
|
||||
);
|
||||
if (group && group.type === 'group') route.sidebar = group.entries;
|
||||
|
||||
const tag = route.id.match(/\/operations\/tags\/([^/]+)$/)?.[1];
|
||||
const title = tag
|
||||
? tag.charAt(0).toUpperCase() + tag.slice(1)
|
||||
: route.id.includes('/operations/')
|
||||
? undefined
|
||||
: `Gitea API ${version.label}`;
|
||||
|
||||
if (title) {
|
||||
const previous = route.entry.data.title;
|
||||
route.entry.data.title = title;
|
||||
// the head tags are built before the route middleware runs, so the document
|
||||
// title has to be replaced as well
|
||||
for (const entry of route.head) {
|
||||
if (entry.tag === 'title' && entry.content?.startsWith(previous)) {
|
||||
entry.content = entry.content.replace(previous, title);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { defineRouteMiddleware } from '@astrojs/starlight/route-data';
|
||||
import { flattenSidebar, markCurrent, metaFromRouteId } from '@gitea-docs/content-loader';
|
||||
import { sidebars } from './lib/sidebars';
|
||||
import { searchMetaTags } from './lib/search';
|
||||
import { versionBanner } from './lib/versions';
|
||||
|
||||
/**
|
||||
* Starlight has a single global sidebar, the gitea docs need one per product,
|
||||
* version and language. The sidebars are built from the loaded pages and the
|
||||
* matching one is selected here, together with the version banner and the
|
||||
* search facets of the page.
|
||||
*/
|
||||
export const onRequest = defineRouteMiddleware((context) => {
|
||||
const route = context.locals.starlightRoute;
|
||||
// the 404 page belongs to no product: the docs are served from the root, so
|
||||
// resolving it by route would place it in the docs and show their version and
|
||||
// language pickers on a page that is not a documentation page
|
||||
if (route.id === '404') return;
|
||||
|
||||
// pages built by starlight-openapi carry no loader metadata, they are placed
|
||||
// in the matrix by their route instead
|
||||
const routeMeta = metaFromRouteId(route.id);
|
||||
const meta =
|
||||
route.entry.data.gitea ?? (routeMeta && { ...routeMeta, dir: '', name: '' });
|
||||
if (!meta) return;
|
||||
// components read the metadata off the entry, so make the resolved one visible
|
||||
route.entry.data.gitea = meta;
|
||||
|
||||
// the api sidebar is built by starlight-openapi after this middleware and
|
||||
// narrowed down in src/middleware/apiSidebar.ts
|
||||
const entries = sidebars.get(meta.prefix);
|
||||
if (entries) {
|
||||
const sidebar = markCurrent(entries, context.url.pathname);
|
||||
route.sidebar = sidebar;
|
||||
|
||||
// pagination is derived from the sidebar, so it has to be recomputed
|
||||
const links = flattenSidebar(sidebar);
|
||||
const index = links.findIndex((link) => link.isCurrent);
|
||||
route.pagination = {
|
||||
prev: index > 0 ? links[index - 1] : undefined,
|
||||
next: index >= 0 && index < links.length - 1 ? links[index + 1] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const banner = versionBanner(meta);
|
||||
if (banner && !route.entry.data.banner) route.entry.data.banner = { content: banner };
|
||||
|
||||
route.head.push(...searchMetaTags(meta));
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { docsSchema } from '@astrojs/starlight/schema';
|
||||
import { z } from 'astro:content';
|
||||
|
||||
/**
|
||||
* Metadata the gitea loader attaches to every page: which part of the product
|
||||
* matrix it belongs to and where it sits in the source tree. Used to build the
|
||||
* per version sidebars, the version and language pickers and the search facets.
|
||||
*/
|
||||
export const giteaMeta = z.object({
|
||||
product: z.enum(['docs', 'api', 'runner', 'enterprise']),
|
||||
version: z.string(),
|
||||
locale: z.string(),
|
||||
prefix: z.string(),
|
||||
/** Directory of the source file, relative to the version directory. */
|
||||
dir: z.string().default(''),
|
||||
/** File name without extension, or directory name for a category page. */
|
||||
name: z.string().default(''),
|
||||
order: z.number().optional(),
|
||||
/** Set on the generated index page of a category. */
|
||||
category: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type GiteaMeta = typeof giteaMeta._output;
|
||||
|
||||
export const giteaDocsSchema = () => docsSchema({ extend: z.object({ gitea: giteaMeta.optional() }) });
|
||||
@@ -0,0 +1,155 @@
|
||||
/* Layout: starlight centres a 45rem column, which leaves a wide gap on the left
|
||||
of the content and gives the table of contents the rest of the space. The
|
||||
docs are reference material with wide tables and code blocks, so the content
|
||||
column is widened and the "On this page" column is pinned to a fixed width
|
||||
instead of growing with the viewport. */
|
||||
:root {
|
||||
--sl-content-width: 52rem;
|
||||
--gitea-toc-width: 22.5rem;
|
||||
/* The header bar stacks the announcement strip on top of the navigation row.
|
||||
Starlight offsets the sidebar, the content and the table of contents by
|
||||
`--sl-nav-height`, so it has to cover both. The navigation row is as tall
|
||||
as the one of docs.gitea.com. */
|
||||
--gitea-announcement-height: 2rem;
|
||||
--gitea-nav-row-height: 3.75rem;
|
||||
--sl-nav-height: calc(var(--gitea-announcement-height) + var(--gitea-nav-row-height));
|
||||
}
|
||||
|
||||
@media (min-width: 72rem) {
|
||||
.right-sidebar-container {
|
||||
width: var(--gitea-toc-width);
|
||||
}
|
||||
|
||||
[data-has-sidebar][data-has-toc] .main-pane {
|
||||
width: calc(100% - var(--gitea-toc-width));
|
||||
/* centre the content in the space left between the two sidebars */
|
||||
--sl-content-margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* A dismissed announcement collapses the strip, which shrinks the header bar
|
||||
and every offset derived from it. */
|
||||
:root[data-gitea-announcement='dismissed'] {
|
||||
--gitea-announcement-height: 0rem;
|
||||
}
|
||||
|
||||
[data-gitea-announcement='dismissed'] .gitea-announcement {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The header bar is laid out by src/components/Header.astro: the strip has to
|
||||
reach the edges and the navigation row provides its own padding. */
|
||||
.page > .header {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Starlight sizes the logo off the navigation height, which here also covers
|
||||
the announcement strip. Pin it to the size docs.gitea.com uses. */
|
||||
.site-title img {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
/* Centre the mobile menu button on the navigation row instead of on the whole
|
||||
header bar, which the announcement strip made taller. */
|
||||
starlight-menu-button button {
|
||||
top: calc(
|
||||
var(--gitea-announcement-height) + (var(--gitea-nav-row-height) - var(--sl-menu-button-size)) / 2
|
||||
);
|
||||
}
|
||||
|
||||
/* Starlight gives the search box the free middle column of the header grid and
|
||||
left aligns it there. Push it to the right, next to the pickers, the theme
|
||||
switch and the sign in button. */
|
||||
@media (min-width: 50rem) {
|
||||
.header > :nth-child(2) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* algolia docsearch brings its own button, which grows to fill the column */
|
||||
.header .DocSearch-Button {
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* The header keeps the product navigation on the left and the pickers, the
|
||||
theme switch and the sign in button on the right. */
|
||||
.gitea-sign-in {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--sl-color-accent);
|
||||
color: var(--sl-color-text-invert);
|
||||
font-size: var(--sl-text-sm);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gitea-sign-in:hover {
|
||||
background-color: var(--sl-color-accent-high);
|
||||
}
|
||||
|
||||
/* Http method badges in the api sidebar. starlight-openapi tags every badge
|
||||
with a `sl-openapi-method-<method>` class; recolour them with the palette of
|
||||
the theme so they follow the light and dark colours, and move them in front
|
||||
of the operation name.
|
||||
|
||||
Every sidebar link is a flex row rather than only the ones holding a badge:
|
||||
a link without one looks the same, and `:has()` would silently drop the whole
|
||||
rule on older browsers, leaving the badge behind the operation name. */
|
||||
.sidebar-content a {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.sidebar-content .sl-badge {
|
||||
order: -1;
|
||||
flex: none;
|
||||
min-width: 3.5em;
|
||||
text-align: center;
|
||||
font-size: var(--sl-text-2xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
font-family: var(--sl-font-mono);
|
||||
padding-inline: 0.3rem;
|
||||
}
|
||||
|
||||
.sl-openapi-method-get,
|
||||
.sl-openapi-method-post,
|
||||
.sl-openapi-method-put,
|
||||
.sl-openapi-method-patch,
|
||||
.sl-openapi-method-delete,
|
||||
.sl-openapi-method-head,
|
||||
.sl-openapi-method-options,
|
||||
.sl-openapi-method-trace {
|
||||
--sl-color-border-badge: transparent;
|
||||
--sl-color-text-badge: var(--gitea-method);
|
||||
--sl-color-bg-badge: color-mix(in srgb, var(--gitea-method) calc(var(--gitea-method-tint) * 100%), transparent);
|
||||
}
|
||||
|
||||
.sl-openapi-method-get {
|
||||
--gitea-method: var(--gitea-method-get);
|
||||
}
|
||||
|
||||
.sl-openapi-method-post {
|
||||
--gitea-method: var(--gitea-method-post);
|
||||
}
|
||||
|
||||
.sl-openapi-method-put {
|
||||
--gitea-method: var(--gitea-method-put);
|
||||
}
|
||||
|
||||
.sl-openapi-method-patch {
|
||||
--gitea-method: var(--gitea-method-patch);
|
||||
}
|
||||
|
||||
.sl-openapi-method-delete {
|
||||
--gitea-method: var(--gitea-method-delete);
|
||||
}
|
||||
|
||||
.sl-openapi-method-head,
|
||||
.sl-openapi-method-options,
|
||||
.sl-openapi-method-trace {
|
||||
--gitea-method: var(--gitea-method-other);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/* Colours of https://about.gitea.com, mapped onto the starlight variables so
|
||||
the documentation matches the rest of the site. The token names in the
|
||||
comments are the ones the about site uses.
|
||||
|
||||
Starlight inverts the meaning of its grey ramp between the two themes:
|
||||
`--sl-color-white` is the strongest text colour and `--sl-color-black` the
|
||||
page background, in both themes. */
|
||||
|
||||
:root {
|
||||
--sl-font:
|
||||
'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, 'Helvetica Neue',
|
||||
Arial, sans-serif;
|
||||
--sl-font-mono:
|
||||
'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Dark, the starlight default theme */
|
||||
:root,
|
||||
::backdrop {
|
||||
--sl-color-white: #e9ebee;
|
||||
--sl-color-gray-1: #d2d4d8; /* app-text */
|
||||
--sl-color-gray-2: #c0c2c7; /* app-text-muted */
|
||||
--sl-color-gray-3: #969aa1; /* app-text-soft */
|
||||
--sl-color-gray-4: #6b6f76;
|
||||
--sl-color-gray-5: #46494f; /* app-border-strong */
|
||||
--sl-color-gray-6: #2c2e32; /* app-surface-subtle */
|
||||
--sl-color-black: #1e1f20; /* app-bg */
|
||||
|
||||
--sl-color-accent-low: #253341; /* app-accent over app-bg */
|
||||
--sl-color-accent: #4183c4; /* app-accent */
|
||||
--sl-color-accent-high: #6ba3d6;
|
||||
--gitea-brand: #4183c4; /* app-accent */
|
||||
|
||||
--sl-color-text: var(--sl-color-gray-1);
|
||||
--sl-color-text-accent: var(--sl-color-accent-high);
|
||||
--sl-color-text-invert: #ffffff;
|
||||
--sl-color-bg: var(--sl-color-black);
|
||||
--sl-color-bg-nav: #2c2e32; /* app-chrome */
|
||||
--sl-color-bg-sidebar: #1a1b1e; /* app-surface-muted */
|
||||
--sl-color-bg-inline-code: #2c2e32;
|
||||
--sl-color-bg-accent: var(--sl-color-accent);
|
||||
--sl-color-hairline-light: #46494f;
|
||||
--sl-color-hairline: #3f4248; /* app-border */
|
||||
--sl-color-hairline-shade: #161718; /* app-surface */
|
||||
--sl-color-backdrop-overlay: rgba(0, 0, 0, 0.72); /* app-overlay */
|
||||
}
|
||||
|
||||
:root[data-theme='light'],
|
||||
[data-theme='light'] ::backdrop {
|
||||
--sl-color-white: #181c21; /* app-text */
|
||||
--sl-color-gray-1: #22262b;
|
||||
--sl-color-gray-2: #40474d; /* app-text-muted */
|
||||
--sl-color-gray-3: #5b6167; /* app-text-soft */
|
||||
--sl-color-gray-4: #8b9198;
|
||||
--sl-color-gray-5: #b9c0c7; /* app-border-strong */
|
||||
--sl-color-gray-6: #d0d7de; /* app-border */
|
||||
--sl-color-gray-7: #f6f7fa; /* app-surface-muted */
|
||||
--sl-color-black: #ffffff; /* app-bg */
|
||||
|
||||
--sl-color-accent-low: #ebf2f9; /* app-surface-subtle */
|
||||
/* the brand blue is 3.99:1 on white, one notch below the contrast needed for
|
||||
body text, so the hover shade of the about site is used for text and for
|
||||
white on blue chips, and the brand blue itself for fills and borders */
|
||||
--sl-color-accent: #3876b3; /* app-link-hover */
|
||||
--sl-color-accent-high: #2b5d8d;
|
||||
--gitea-brand: #4183c4; /* app-accent */
|
||||
|
||||
--sl-color-text: var(--sl-color-white);
|
||||
--sl-color-text-accent: var(--sl-color-accent);
|
||||
--sl-color-text-invert: #ffffff;
|
||||
--sl-color-bg-nav: #f6f7fa; /* app-chrome */
|
||||
--sl-color-bg-sidebar: #f6f7fa;
|
||||
--sl-color-bg-inline-code: #ebf2f9;
|
||||
--sl-color-bg-accent: var(--sl-color-accent);
|
||||
--sl-color-hairline-light: #e3e7eb;
|
||||
--sl-color-hairline: #d0d7de; /* app-border */
|
||||
--sl-color-hairline-shade: #d0d7de;
|
||||
--sl-color-backdrop-overlay: rgba(0, 0, 23, 0.45); /* app-overlay */
|
||||
}
|
||||
|
||||
/* Http method colours of the api sidebar. Muted rather than saturated: there
|
||||
are a few hundred of these chips on a page, and the starlight palette hues
|
||||
are loud enough to turn the sidebar into a rainbow. Tinted background, no
|
||||
border, text carrying the colour. */
|
||||
:root {
|
||||
--gitea-method-get: #56d364;
|
||||
--gitea-method-post: #79b8e8;
|
||||
--gitea-method-put: #b392f0;
|
||||
--gitea-method-patch: #f0883e;
|
||||
--gitea-method-delete: #ff7b72;
|
||||
--gitea-method-other: var(--sl-color-gray-3);
|
||||
--gitea-method-tint: 0.15;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--gitea-method-get: #166534;
|
||||
--gitea-method-post: #2a5f92;
|
||||
--gitea-method-put: #5b32a8;
|
||||
--gitea-method-patch: #9a3d00;
|
||||
--gitea-method-delete: #b3181f;
|
||||
--gitea-method-other: var(--sl-color-gray-3);
|
||||
--gitea-method-tint: 0.1;
|
||||
}
|
||||
|
||||
/* Links inside the content follow the about site: accent, darker on hover. */
|
||||
.sl-markdown-content a:not(:where(.not-content *)) {
|
||||
color: var(--sl-color-text-accent);
|
||||
}
|
||||
|
||||
.sl-markdown-content a:not(:where(.not-content *)):hover {
|
||||
color: var(--sl-color-accent-high);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*", "../../packages/content-loader/src/**/*"],
|
||||
"exclude": ["dist", ".cache"]
|
||||
}
|
||||
Reference in New Issue
Block a user