Rebuild the site with Astro and Starlight (#496)

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

## Summary

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

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

## Why

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

## What is in here

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

## Bugs fixed along the way

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

## Url compatibility

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

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

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

## Workflows

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

## Removed

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

## Follow ups

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

## Testing

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

## Screenshots

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

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

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

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/docs/pulls/496
Reviewed-by: bircni <[email protected]>
This commit is contained in:
Lunny Xiao
2026-08-12 20:03:29 +00:00
co-authored by bircni
parent c78e0d42a9
commit b137a0e0e1
99 changed files with 6701 additions and 13674 deletions
+120
View File
@@ -0,0 +1,120 @@
# Astro + Starlight site
Renders the gitea documentation with [Starlight](https://starlight.astro.build):
the docs (6 versions x 3 languages), the runner docs (4 series) and the api
reference (7 swagger documents). The enterprise docs stay in their own
deployment and are linked from the header.
The content directories are the ones the site has always used — `docs/`,
`versioned_docs/`, `i18n/`, `runner-docs/`, `runner-docs_versioned_docs/` and
`static/swagger-*.json` — so the release scripts and the translation workflow
stay unchanged.
## Running it
Everything is driven from the repository root, see the `README.md` there:
```shell
make serve-fast # english, the version served at the root, starts in seconds
make serve # whole matrix, api included
make serve-built # build and serve it, the only way to try the search locally
make build
make check
```
`GITEA_DOCS_PRODUCTS`, `GITEA_DOCS_VERSIONS` and `GITEA_DOCS_LOCALES` (comma
separated) restrict the matrix, which is what `make serve-fast` uses.
## How it is put together
`packages/content-loader` holds the product matrix and the astro content loader.
`src/products.ts` is the single source of truth for product x version x language
and drives everything else: content loading, sidebars, the version and language
pickers, the version banner, the search facets and the api schemas.
| Source | Route |
| --- | --- |
| `versioned_docs/version-1.27/` | `/` |
| `docs/` | `/next/` |
| `versioned_docs/version-1.26/` | `/1.26/` |
| `i18n/zh-cn/docusaurus-plugin-content-docs/version-1.27/` | `/zh-cn/` |
| `runner-docs_versioned_docs/version-3/` | `/runner/` |
| `runner-docs/` | `/runner/develop/` |
| `static/swagger-27.json` | `/api/` |
| `static/swagger-latest.json` | `/api/next/` |
The loader reads the markdown in two passes. The first one works out the route
of every file, the second normalizes and renders it:
- substitutes the version variables (`@version@`, `@dockerVersion@`, ...)
- lifts the leading `# heading` into the starlight `title`
- maps `sidebar_position` to `sidebar.order` and honours the docusaurus `slug`,
including the translations that moved a page
- rewrites the relative `*.md` links to urls, resolving them against the file
and against the version root, and reports the ones that do not resolve
- turns the docusaurus admonitions into starlight asides (`:::warning` to
`:::caution`, `:::info` to `:::note`, `:::note Title` to `:::note[Title]`)
- recreates the index pages of `_category_.json` (`/category/installation/`,
`/usage/actions/`)
Sidebars are built per (product, version, language) from `_category_.json` and
from the docusaurus sidebar files (`sidebars.js`, `versioned_sidebars/`,
`runner-sidebars.js`, `runner-docs_versioned_sidebars/`), and selected in
`src/routeData.ts`, since starlight only supports a single static sidebar.
Set `GITEA_DOCS_STRICT_LINKS=true` to fail the build on a broken internal link
instead of warning.
## Products and languages
The docs are the site itself, so their language comes first: `/zh-cn/1.26/`.
The runner and the api are separate products and keep their language inside the
product, `/runner/zh-cn/3/`, which is reserved: both are published in english
only today. Starlight builds a fallback page in every configured language for
every english page, so `/zh-cn/runner/` would exist; the post build integration
removes those directories and `cloudflare/_redirects` sends them to the
english page.
The version served at the root of a product has no number in its url: docs 1.27
is `/`, runner 3 is `/runner/` and api 1.27 is `/api/`. `/1.27/`, `/runner/3/`
and `/api/1.27/` are redirected onto them in `cloudflare/_redirects`.
## Api reference
`starlight-openapi` generates a page per operation from the seven swagger
documents `update_api_docs.sh` maintains. Two adjustments happen at build time:
- the documents set `basePath` to the full `https://gitea.com/api/v1` url, which
redoc accepted but is not valid swagger 2.0. A normalized copy is written to
`.cache/openapi/` instead of touching the sources.
- the plugin slugifies the base path, so `/api/1.26/` is generated as
`/api/126/`. `src/integrations/postbuild.ts` renames the directories and
rewrites the links once the build is done.
## Search
Algolia docsearch, enabled when `PUBLIC_DOCSEARCH_APP_ID` and
`PUBLIC_DOCSEARCH_API_KEY` are set, pagefind otherwise, so a fork without
credentials still gets search. Every page carries `docsearch:product`,
`docsearch:version` and `docsearch:language` meta tags; the crawler turns them
into facets and `src/config/docsearch.ts` filters the search on the product,
version and language being read. `cloudflare/docsearch-crawler.json` holds the
crawler configuration and only indexes the versions people read.
## Deployment
`cloudflare/_headers` and `cloudflare/_redirects` are copied next to the build
output by the publish workflow. `/enterprise/*` is routed to the enterprise
pages project by a Cloudflare worker maintained outside this repository.
## Differences between `pnpm dev` and the deployed site
- `/api/1.26/` is served at `/api/126/`, the renaming happens after the build.
- `/zh-cn/runner/` still answers, the fallback pages are removed after the build.
- `/1.27/`, `/runner/3/` and `/api/1.27/` are cloudflare redirects, so they only
work on the deployed site.
- search is built by pagefind at build time and only answers on the built site,
unless the algolia docsearch credentials are set; `make serve-built` builds and
serves it locally.
- a scoped run only builds part of the matrix, so the products and versions left
out answer with the 404 page; `pnpm dev` serves everything.
+141
View File
@@ -0,0 +1,141 @@
// @ts-check
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import starlight from '@astrojs/starlight';
import { defineConfig } from 'astro/config';
import starlightDocSearch from '@astrojs/starlight-docsearch';
import starlightOpenAPI, { openAPISidebarGroups } from 'starlight-openapi';
import { apiSchemas } from './src/config/api.js';
import { analytics, announcement, announcementStorageKey } from './src/config/site.js';
import { giteaApiSidebar } from './src/integrations/api.js';
import { giteaPostBuild } from './src/integrations/postbuild.js';
const repoRoot = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../..');
// algolia docsearch replaces the local search once the credentials are set; the
// build falls back to pagefind so that a fork without them still gets search
const useDocSearch = Boolean(
process.env.PUBLIC_DOCSEARCH_APP_ID && process.env.PUBLIC_DOCSEARCH_API_KEY,
);
export default defineConfig({
site: 'https://docs.gitea.com',
trailingSlash: 'always',
// images, logos and the swagger documents are served from `static/`
publicDir: path.join(repoRoot, 'static'),
// the markdown sources live outside this package, allow the dev server to read them
vite: {
server: { fs: { allow: [repoRoot] } },
plugins: [
{
// The api overview of starlight-openapi lists every operation of the
// document, which repeats the sidebar; swap that section for the tag
// cards of src/components/ApiOverviewTags.astro. The plugin has no
// option for it, so the import is redirected instead.
name: 'gitea-openapi-overview',
enforce: 'pre',
resolveId(source, importer) {
if (
source === './OverviewNavigationLinks.astro' &&
importer?.includes('starlight-openapi/components/overview/OverviewSchema.astro')
) {
return path.join(repoRoot, 'sites/docs/src/components/ApiOverviewTags.astro');
}
return null;
},
},
],
},
// languages the sources tag code blocks with that shiki does not know
markdown: {
shikiConfig: {
langAlias: {
apacheconf: 'apache',
conf: 'ini',
curl: 'bash',
gitignore: 'text',
none: 'text',
plantuml: 'text',
tmpl: 'handlebars',
},
},
},
integrations: [
giteaPostBuild(),
starlight({
title: 'Gitea Documentation',
description: 'Git with a cup of tea',
favicon: '/img/favicon.png',
logo: { src: './src/assets/gitea.svg', alt: 'Gitea', replacesTitle: true },
defaultLocale: 'root',
locales: {
root: { label: 'English', lang: 'en-US' },
'zh-cn': { label: '简体中文', lang: 'zh-CN' },
'zh-tw': { label: '繁體中文', lang: 'zh-TW' },
},
plugins: [
starlightOpenAPI(apiSchemas),
// after starlight-openapi, so it sees the groups the plugin built
giteaApiSidebar(),
...(useDocSearch
? [starlightDocSearch({ clientOptionsModule: './src/config/docsearch.ts' })]
: []),
],
// one sidebar per product, version and language, selected in the route
// middleware; starlight only supports a single static one
customCss: ['./src/styles/theme.css', './src/styles/custom.css'],
components: {
Header: './src/components/Header.astro',
LanguageSelect: './src/components/LanguageSelect.astro',
MarkdownContent: './src/components/MarkdownContent.astro',
SiteTitle: './src/components/SiteTitle.astro',
PageFrame: './src/components/PageFrame.astro',
},
head: [
{
// hide a dismissed announcement before the first paint, so the page
// does not jump once the stylesheet and the scripts have loaded
tag: 'script',
content: `try{if(localStorage.getItem(${JSON.stringify(
announcementStorageKey,
)})===${JSON.stringify(
announcement.id,
)})document.documentElement.dataset.giteaAnnouncement='dismissed'}catch(e){}`,
},
{
tag: 'script',
attrs: { async: true, src: `https://www.googletagmanager.com/gtag/js?id=${analytics.gtagId}` },
},
{
tag: 'script',
content: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${analytics.gtagId}');`,
},
{
tag: 'script',
attrs: {
defer: true,
'data-domain': analytics.plausibleDomain,
src: 'https://plausible.io/js/script.js',
},
},
{ tag: 'meta', attrs: { property: 'og:logo', content: '/img/gitea.svg' } },
{
tag: 'meta',
attrs: {
name: 'keywords',
content:
'gitea, git, devops, actions, packages, documentation, self-hosted, open-source, version control, gitlab, github',
},
},
],
// the api groups are needed here so starlight-openapi pages get a sidebar,
// the route middleware narrows it down to the version being read
sidebar: [...openAPISidebarGroups],
routeMiddleware: './src/routeData.ts',
// the sources live outside src/content/docs, starlight only runs its
// markdown transforms (asides, heading anchors) on files below these
markdown: { processedDirs: ['../..'] },
pagefind: !useDocSearch,
}),
],
});
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@gitea-docs/site",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"dev:en-latest": "GITEA_DOCS_PRODUCTS=docs,runner GITEA_DOCS_VERSIONS=1.27,3 GITEA_DOCS_LOCALES=en-us astro dev",
"build": "astro build",
"preview": "astro preview",
"check": "astro check"
},
"dependencies": {
"@astrojs/starlight": "0.41.7",
"@astrojs/starlight-docsearch": "0.7.0",
"@gitea-docs/content-loader": "workspace:*",
"astro": "7.2.0",
"sharp": "0.34.5",
"starlight-openapi": "0.26.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"typescript": "^5.9.3"
}
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="main_outline" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" viewBox="0 0 640 640" style="enable-background:new 0 0 640 640;" xml:space="preserve">
<g>
<path id="teabag" style="fill:#FFFFFF" d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8
c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4
c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"/>
<g>
<g>
<path style="fill:#609926" d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2
c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5
c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5
c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3
c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1
C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4
c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7
S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55
c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8
l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"/>
<path style="fill:#609926" d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4
c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1
c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9
c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3
c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3
c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29
c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8
C343.2,346.5,335,363.3,326.8,380.1z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

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