feat: add a cli product for the gt documentation

gt, the Gitea CLI, gets its own product next to the runner: the CLI entry
in the header, its own sidebars, search scope, crawler start url and
redirects, and `make cut-version PRODUCT=cli`. Until a release series is
listed, the develop docs are served at /cli/ without the unreleased banner.

A directory with an index page and no _category_.json now takes its
sidebar label and position from that page, which leads the group, so the
generated reference can be grouped by command.

Signed-off-by: bircni <[email protected]>
This commit is contained in:
bircni committed 2026-10-22 11:28:15 +02:00
1 parent 49d912f7fb
commit fe618c42be
10 files changed
+104 -41

No files matched your search

+1
View File
@@ -0,0 +1 @@
[]
+4 -2
View File
@@ -1,11 +1,13 @@
# Redirects for legacy URLs and routes the site no longer serves directly.
# The api reference and the runner docs are separate products, published in
# english only, so they have no localized copy.
# The api reference, the runner and the cli docs are separate products,
# published in english only, so they have no localized copy.
/zh-cn/api/* /api/:splat 301
/zh-tw/api/* /api/:splat 301
/zh-cn/runner/* /runner/:splat 301
/zh-tw/runner/* /runner/:splat 301
/zh-cn/cli/* /cli/:splat 301
/zh-tw/cli/* /cli/:splat 301
# The version served at the root of a product is also reachable under its
# number, which is what the release notes and old links use.
+3 -1
View File
@@ -7,6 +7,7 @@
"https://docs.gitea.com/zh-cn/",
"https://docs.gitea.com/zh-tw/",
"https://docs.gitea.com/runner/",
"https://docs.gitea.com/cli/",
"https://docs.gitea.com/api/",
"https://docs.gitea.com/enterprise/"
],
@@ -15,7 +16,8 @@
"https://docs.gitea.com/1.2*/**",
"https://docs.gitea.com/*/1.2*/**",
"https://docs.gitea.com/api/1.2*/**",
"https://docs.gitea.com/runner/[0-9]/**"
"https://docs.gitea.com/runner/[0-9]/**",
"https://docs.gitea.com/cli/[0-9]*/**"
],
"selectors": {
"lvl0": { "selector": "//nav[contains(@aria-labelledby,'starlight__sidebar')]//a[@aria-current='page']/ancestor::details//summary//span", "type": "xpath", "global": true, "default_value": "Documentation" },
+45 -2
View File
@@ -8,7 +8,7 @@
export type LocaleId = 'en-us' | 'zh-cn' | 'zh-tw';
export type ProductId = 'docs' | 'api' | 'runner' | 'enterprise';
export type ProductId = 'docs' | 'api' | 'runner' | 'cli' | 'enterprise';
/** Values substituted for `@name@` placeholders in the markdown sources. */
export interface VersionVariables {
@@ -246,6 +246,43 @@ const runnerProduct: ProductDef = {
],
};
/**
* gt, the Gitea CLI: one directory per release series, english only, like the
* runner. The label is the newest release of the series, which is also the tag
* the generated reference pages of that directory come from.
*
* Until the first series is listed here the develop docs are served at the
* product root, without the unreleased banner, since there is no release to
* point to. Listing a series moves them to `/cli/develop/`.
*/
const cliSeries: { id: string; latestRelease: string }[] = [];
const cliProduct: ProductDef = {
id: 'cli',
label: 'CLI',
base: 'cli',
locales: ['en-us'],
versions: [
{
id: 'develop',
label: 'develop',
sources: { 'en-us': 'cli-docs' },
...(cliSeries.length > 0
? { path: 'develop', banner: 'unreleased' as const }
: { path: '', latest: true }),
},
...cliSeries.map(
({ id, latestRelease }, index): VersionDef => ({
id,
label: latestRelease,
path: index === 0 ? '' : id,
latest: index === 0,
sources: { 'en-us': `cli-docs_versioned_docs/version-${id}` },
}),
),
],
};
/** API reference, rendered from the swagger documents by starlight-openapi. */
const apiProduct: ProductDef = {
id: 'api',
@@ -301,7 +338,13 @@ const enterpriseProduct: ProductDef = {
externalBaseUrl: 'https://docs.gitea.com/enterprise',
};
export const products: ProductDef[] = [docsProduct, apiProduct, runnerProduct, enterpriseProduct];
export const products: ProductDef[] = [
docsProduct,
apiProduct,
runnerProduct,
cliProduct,
enterpriseProduct,
];
export function getProduct(id: ProductId): ProductDef {
const product = products.find((candidate) => candidate.id === id);
+17 -8
View File
@@ -96,14 +96,23 @@ interface Tree {
label(dir: string): string;
}
/** Sidebar of a directory tree, ordered by `sidebar_position`. */
/**
* Sidebar of a directory tree, ordered by `sidebar_position`. A directory
* without `_category_.json` but with an `index` page, as the generated cli
* reference has one per command, takes its label and position from that page,
* which comes first in it.
*/
function treeOf(pages: SidebarPage[]): Tree {
const categories = new Map<string, SidebarPage>();
const indexes = new Map<string, SidebarPage>();
for (const page of pages) {
if (!page.meta.category) continue;
const dir = [page.meta.dir, page.meta.name].filter(Boolean).join('/');
categories.set(dir, page);
if (page.meta.category) {
categories.set([page.meta.dir, page.meta.name].filter(Boolean).join('/'), page);
} else if (page.meta.name === 'index' && page.meta.dir) {
indexes.set(page.meta.dir, page);
}
}
const heading = (dir: string) => categories.get(dir) ?? indexes.get(dir);
const childDirs = (dir: string) => {
const prefix = dir ? `${dir}/` : '';
@@ -121,7 +130,7 @@ function treeOf(pages: SidebarPage[]): Tree {
const entries: { sort: [number, number, string]; entry: SidebarEntry }[] = [];
for (const child of childDirs(dir)) {
const category = categories.get(child);
const category = heading(child);
const label = category?.title ?? labelFromDirname(child.split('/').at(-1)!);
entries.push({
sort: [category?.meta.order ?? last, 0, label],
@@ -131,7 +140,8 @@ function treeOf(pages: SidebarPage[]): Tree {
for (const page of pages) {
if (page.meta.dir !== dir || page.meta.category || page.hidden) continue;
entries.push({ sort: [page.meta.order ?? last, 1, page.title], entry: link(page) });
const order = indexes.get(dir) === page ? -last : (page.meta.order ?? last);
entries.push({ sort: [order, 1, page.title], entry: link(page) });
}
return entries
@@ -142,8 +152,7 @@ function treeOf(pages: SidebarPage[]): Tree {
.map((item) => item.entry);
};
const label = (dir: string) =>
categories.get(dir)?.title ?? labelFromDirname(dir.split('/').at(-1)!);
const label = (dir: string) => heading(dir)?.title ?? labelFromDirname(dir.split('/').at(-1)!);
return { build, label };
}
+10 -8
View File
@@ -4,10 +4,11 @@
*
* node scripts/cut-version.mjs docs 1.28 # freezes docs/ as 1.28
* node scripts/cut-version.mjs runner 4 # freezes runner-docs/ as 4
* node scripts/cut-version.mjs cli 0.1 # freezes cli-docs/ as 0.1
*
* For the docs it copies the english tree, every translation and the sidebar
* file; for the runner the english tree and its sidebar. The version list
* (`versions.json`, `runner-docs_versions.json`) is updated too.
* file; for the runner and the cli the english tree and its sidebar. The
* version list (`versions.json`, `<product>-docs_versions.json`) is updated too.
*
* The label, the release variables (`@version@`, `@dockerVersion@`, ...) and
* which version is served at the root live in
@@ -21,8 +22,8 @@ import process from 'node:process';
const [product, version] = process.argv.slice(2);
if (!product || !version || !['docs', 'runner'].includes(product)) {
console.error('usage: node scripts/cut-version.mjs <docs|runner> <version>');
if (!product || !version || !['docs', 'runner', 'cli'].includes(product)) {
console.error('usage: node scripts/cut-version.mjs <docs|runner|cli> <version>');
process.exit(1);
}
@@ -52,6 +53,7 @@ async function freeze(from, to) {
async function freezeSidebar(from, to) {
if (await exists(to)) throw new Error(`${to} already exists`);
const sidebar = require(path.join(root, from));
await mkdir(path.join(root, path.dirname(to)), { recursive: true });
await writeFile(path.join(root, to), `${JSON.stringify(sidebar, null, 2)}\n`);
console.log(` ${from} -> ${to}`);
}
@@ -80,12 +82,12 @@ if (product === 'docs') {
await addToVersions('versions.json');
} else {
await freeze('runner-docs', `runner-docs_versioned_docs/version-${version}`);
await freeze(`${product}-docs`, `${product}-docs_versioned_docs/version-${version}`);
await freezeSidebar(
'runner-sidebars.js',
`runner-docs_versioned_sidebars/version-${version}-sidebars.json`,
`${product}-sidebars.js`,
`${product}-docs_versioned_sidebars/version-${version}-sidebars.json`,
);
await addToVersions('runner-docs_versions.json');
await addToVersions(`${product}-docs_versions.json`);
}
console.log(
+12 -8
View File
@@ -1,12 +1,13 @@
# 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
the docs (6 versions x 3 languages), the runner docs (4 series), the gt (Gitea
CLI) docs 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
`versioned_docs/`, `i18n/`, `runner-docs/`, `runner-docs_versioned_docs/`,
`cli-docs/` and
`static/swagger-*.json` (plus `static/openapi3-*.json` since gitea 1.27) — so
the release scripts and the translation workflow stay unchanged.
@@ -40,6 +41,7 @@ pickers, the version banner, the search facets and the api schemas.
| `i18n/zh-cn/docs/version-1.27/` | `/zh-cn/` |
| `runner-docs_versioned_docs/version-3/` | `/runner/` |
| `runner-docs/` | `/runner/develop/` |
| `cli-docs/` | `/cli/`, until a cli series is released |
| `static/openapi3-27.json` | `/api/` |
| `static/openapi3-latest.json` | `/api/next/` |
| `static/swagger-26.json` | `/api/1.26/` |
@@ -60,8 +62,10 @@ of every file, the second normalizes and renders it:
Sidebars are built per (product, version, language) from `_category_.json` and
from the 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.
`runner-sidebars.js`, `runner-docs_versioned_sidebars/`, `cli-sidebars.js`),
and selected in `src/routeData.ts`, since starlight only supports a single
static sidebar. A directory without `_category_.json` takes the label and the
position of its `index` page, which the generated cli reference relies on.
Set `GITEA_DOCS_STRICT_LINKS=true` to fail the build on a broken internal link
instead of warning.
@@ -69,9 +73,9 @@ 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
The runner, the cli and the api are separate products and keep their language
inside the product, `/runner/zh-cn/3/`, which is reserved: all 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.
+3 -3
View File
@@ -23,7 +23,7 @@ type Strings = {
const en: Strings = {
versionLabel: 'Version',
languageLabel: 'Language',
products: { docs: 'Docs', api: 'API', runner: 'Runner', enterprise: 'Enterprise' },
products: { docs: 'Docs', api: 'API', runner: 'Runner', cli: 'CLI', enterprise: 'Enterprise' },
footer: { Community: 'Community', Code: 'Code', More: 'More' },
signIn: 'Sign In',
translationNotice: 'This translation may be behind the english original.',
@@ -41,7 +41,7 @@ const strings: Record<string, Strings> = {
'zh-cn': {
versionLabel: '版本',
languageLabel: '语言',
products: { docs: '文档', api: 'API', runner: 'Runner', enterprise: '企业版' },
products: { docs: '文档', api: 'API', runner: 'Runner', cli: 'CLI', enterprise: '企业版' },
footer: { Community: '社区', Code: '开源代码', More: '更多' },
signIn: '登录',
translationNotice: '当前中文文档翻译不是最新版,访问英文版本查看最新内容,或',
@@ -56,7 +56,7 @@ const strings: Record<string, Strings> = {
'zh-tw': {
versionLabel: '版本',
languageLabel: '語言',
products: { docs: '文件', api: 'API', runner: 'Runner', enterprise: '企業版' },
products: { docs: '文件', api: 'API', runner: 'Runner', cli: 'CLI', enterprise: '企業版' },
footer: { Community: '社區', Code: '開源程式碼', More: '更多' },
signIn: '登入',
translationNotice: '當前中文文檔翻譯不是最新版,訪問英文版本查看最新內容,或',
+8 -8
View File
@@ -12,9 +12,9 @@ import { getCollection } from 'astro:content';
import { repoRoot } from '../config/paths';
/**
* Sidebar files: 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.
* Sidebar files: the top level order of the docs and the handwritten sidebars
* of the runner and the cli, one per version. Read as is, so cutting a version
* keeps needing no change here.
*/
function sidebarSources(): Map<string, SidebarSourceItem[]> {
const sidebars = new Map<string, SidebarSourceItem[]>();
@@ -42,18 +42,18 @@ function sidebarSources(): Map<string, SidebarSourceItem[]> {
version.id === 'next'
? 'sidebars.js'
: `versioned_sidebars/version-${version.id}-sidebars.json`;
} else if (product.id === 'runner') {
files[`runner@${version.id}`] =
} else if (product.id === 'runner' || product.id === 'cli') {
files[`${product.id}@${version.id}`] =
version.id === 'develop'
? 'runner-sidebars.js'
: `runner-docs_versioned_sidebars/version-${version.id}-sidebars.json`;
? `${product.id}-sidebars.js`
: `${product.id}-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]);
const items = config && (config.docs ?? config.runner ?? config.cli ?? Object.values(config)[0]);
if (items) sidebars.set(key, items);
}
return sidebars;
+1 -1
View File
@@ -7,7 +7,7 @@ import { z } from 'astro:content';
* per version sidebars, the version and language pickers and the search facets.
*/
export const giteaMeta = z.object({
product: z.enum(['docs', 'api', 'runner', 'enterprise']),
product: z.enum(['docs', 'api', 'runner', 'cli', 'enterprise']),
version: z.string(),
locale: z.string(),
prefix: z.string(),