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. # Redirects for legacy URLs and routes the site no longer serves directly.
# The api reference and the runner docs are separate products, published in # The api reference, the runner and the cli docs are separate products,
# english only, so they have no localized copy. # published in english only, so they have no localized copy.
/zh-cn/api/* /api/:splat 301 /zh-cn/api/* /api/:splat 301
/zh-tw/api/* /api/:splat 301 /zh-tw/api/* /api/:splat 301
/zh-cn/runner/* /runner/:splat 301 /zh-cn/runner/* /runner/:splat 301
/zh-tw/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 # 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. # 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-cn/",
"https://docs.gitea.com/zh-tw/", "https://docs.gitea.com/zh-tw/",
"https://docs.gitea.com/runner/", "https://docs.gitea.com/runner/",
"https://docs.gitea.com/cli/",
"https://docs.gitea.com/api/", "https://docs.gitea.com/api/",
"https://docs.gitea.com/enterprise/" "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/*/1.2*/**", "https://docs.gitea.com/*/1.2*/**",
"https://docs.gitea.com/api/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": { "selectors": {
"lvl0": { "selector": "//nav[contains(@aria-labelledby,'starlight__sidebar')]//a[@aria-current='page']/ancestor::details//summary//span", "type": "xpath", "global": true, "default_value": "Documentation" }, "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 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. */ /** Values substituted for `@name@` placeholders in the markdown sources. */
export interface VersionVariables { 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. */ /** API reference, rendered from the swagger documents by starlight-openapi. */
const apiProduct: ProductDef = { const apiProduct: ProductDef = {
id: 'api', id: 'api',
@@ -301,7 +338,13 @@ const enterpriseProduct: ProductDef = {
externalBaseUrl: 'https://docs.gitea.com/enterprise', 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 { export function getProduct(id: ProductId): ProductDef {
const product = products.find((candidate) => candidate.id === id); const product = products.find((candidate) => candidate.id === id);
+17 -8
View File
@@ -96,14 +96,23 @@ interface Tree {
label(dir: string): string; 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 { function treeOf(pages: SidebarPage[]): Tree {
const categories = new Map<string, SidebarPage>(); const categories = new Map<string, SidebarPage>();
const indexes = new Map<string, SidebarPage>();
for (const page of pages) { for (const page of pages) {
if (!page.meta.category) continue; if (page.meta.category) {
const dir = [page.meta.dir, page.meta.name].filter(Boolean).join('/'); categories.set([page.meta.dir, page.meta.name].filter(Boolean).join('/'), page);
categories.set(dir, 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 childDirs = (dir: string) => {
const prefix = dir ? `${dir}/` : ''; const prefix = dir ? `${dir}/` : '';
@@ -121,7 +130,7 @@ function treeOf(pages: SidebarPage[]): Tree {
const entries: { sort: [number, number, string]; entry: SidebarEntry }[] = []; const entries: { sort: [number, number, string]; entry: SidebarEntry }[] = [];
for (const child of childDirs(dir)) { for (const child of childDirs(dir)) {
const category = categories.get(child); const category = heading(child);
const label = category?.title ?? labelFromDirname(child.split('/').at(-1)!); const label = category?.title ?? labelFromDirname(child.split('/').at(-1)!);
entries.push({ entries.push({
sort: [category?.meta.order ?? last, 0, label], sort: [category?.meta.order ?? last, 0, label],
@@ -131,7 +140,8 @@ function treeOf(pages: SidebarPage[]): Tree {
for (const page of pages) { for (const page of pages) {
if (page.meta.dir !== dir || page.meta.category || page.hidden) continue; 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 return entries
@@ -142,8 +152,7 @@ function treeOf(pages: SidebarPage[]): Tree {
.map((item) => item.entry); .map((item) => item.entry);
}; };
const label = (dir: string) => const label = (dir: string) => heading(dir)?.title ?? labelFromDirname(dir.split('/').at(-1)!);
categories.get(dir)?.title ?? labelFromDirname(dir.split('/').at(-1)!);
return { build, label }; 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 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 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 * 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 * file; for the runner and the cli the english tree and its sidebar. The
* (`versions.json`, `runner-docs_versions.json`) is updated too. * version list (`versions.json`, `<product>-docs_versions.json`) is updated too.
* *
* The label, the release variables (`@version@`, `@dockerVersion@`, ...) and * The label, the release variables (`@version@`, `@dockerVersion@`, ...) and
* which version is served at the root live in * 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); const [product, version] = process.argv.slice(2);
if (!product || !version || !['docs', 'runner'].includes(product)) { if (!product || !version || !['docs', 'runner', 'cli'].includes(product)) {
console.error('usage: node scripts/cut-version.mjs <docs|runner> <version>'); console.error('usage: node scripts/cut-version.mjs <docs|runner|cli> <version>');
process.exit(1); process.exit(1);
} }
@@ -52,6 +53,7 @@ async function freeze(from, to) {
async function freezeSidebar(from, to) { async function freezeSidebar(from, to) {
if (await exists(to)) throw new Error(`${to} already exists`); if (await exists(to)) throw new Error(`${to} already exists`);
const sidebar = require(path.join(root, from)); 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`); await writeFile(path.join(root, to), `${JSON.stringify(sidebar, null, 2)}\n`);
console.log(` ${from} -> ${to}`); console.log(` ${from} -> ${to}`);
} }
@@ -80,12 +82,12 @@ if (product === 'docs') {
await addToVersions('versions.json'); await addToVersions('versions.json');
} else { } else {
await freeze('runner-docs', `runner-docs_versioned_docs/version-${version}`); await freeze(`${product}-docs`, `${product}-docs_versioned_docs/version-${version}`);
await freezeSidebar( await freezeSidebar(
'runner-sidebars.js', `${product}-sidebars.js`,
`runner-docs_versioned_sidebars/version-${version}-sidebars.json`, `${product}-docs_versioned_sidebars/version-${version}-sidebars.json`,
); );
await addToVersions('runner-docs_versions.json'); await addToVersions(`${product}-docs_versions.json`);
} }
console.log( console.log(
+12 -8
View File
@@ -1,12 +1,13 @@
# Astro + Starlight site # Astro + Starlight site
Renders the gitea documentation with [Starlight](https://starlight.astro.build): 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 the docs (6 versions x 3 languages), the runner docs (4 series), the gt (Gitea
reference (7 swagger documents). The enterprise docs stay in their own CLI) docs and the api reference (7 swagger documents). The enterprise docs stay in their own
deployment and are linked from the header. deployment and are linked from the header.
The content directories are the ones the site has always used — `docs/`, 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 `static/swagger-*.json` (plus `static/openapi3-*.json` since gitea 1.27) — so
the release scripts and the translation workflow stay unchanged. 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/` | | `i18n/zh-cn/docs/version-1.27/` | `/zh-cn/` |
| `runner-docs_versioned_docs/version-3/` | `/runner/` | | `runner-docs_versioned_docs/version-3/` | `/runner/` |
| `runner-docs/` | `/runner/develop/` | | `runner-docs/` | `/runner/develop/` |
| `cli-docs/` | `/cli/`, until a cli series is released |
| `static/openapi3-27.json` | `/api/` | | `static/openapi3-27.json` | `/api/` |
| `static/openapi3-latest.json` | `/api/next/` | | `static/openapi3-latest.json` | `/api/next/` |
| `static/swagger-26.json` | `/api/1.26/` | | `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 Sidebars are built per (product, version, language) from `_category_.json` and
from the sidebar files (`sidebars.js`, `versioned_sidebars/`, from the sidebar files (`sidebars.js`, `versioned_sidebars/`,
`runner-sidebars.js`, `runner-docs_versioned_sidebars/`), and selected in `runner-sidebars.js`, `runner-docs_versioned_sidebars/`, `cli-sidebars.js`),
`src/routeData.ts`, since starlight only supports a single static sidebar. 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 Set `GITEA_DOCS_STRICT_LINKS=true` to fail the build on a broken internal link
instead of warning. instead of warning.
@@ -69,9 +73,9 @@ instead of warning.
## Products and languages ## Products and languages
The docs are the site itself, so their language comes first: `/zh-cn/1.26/`. 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 The runner, the cli and the api are separate products and keep their language
product, `/runner/zh-cn/3/`, which is reserved: both are published in english inside the product, `/runner/zh-cn/3/`, which is reserved: all are published in
only today. Starlight builds a fallback page in every configured language for 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 every english page, so `/zh-cn/runner/` would exist; the post build integration
removes those directories and `cloudflare/_redirects` sends them to the removes those directories and `cloudflare/_redirects` sends them to the
english page. english page.
+3 -3
View File
@@ -23,7 +23,7 @@ type Strings = {
const en: Strings = { const en: Strings = {
versionLabel: 'Version', versionLabel: 'Version',
languageLabel: 'Language', 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' }, footer: { Community: 'Community', Code: 'Code', More: 'More' },
signIn: 'Sign In', signIn: 'Sign In',
translationNotice: 'This translation may be behind the english original.', translationNotice: 'This translation may be behind the english original.',
@@ -41,7 +41,7 @@ const strings: Record<string, Strings> = {
'zh-cn': { 'zh-cn': {
versionLabel: '版本', versionLabel: '版本',
languageLabel: '语言', languageLabel: '语言',
products: { docs: '文档', api: 'API', runner: 'Runner', enterprise: '企业版' }, products: { docs: '文档', api: 'API', runner: 'Runner', cli: 'CLI', enterprise: '企业版' },
footer: { Community: '社区', Code: '开源代码', More: '更多' }, footer: { Community: '社区', Code: '开源代码', More: '更多' },
signIn: '登录', signIn: '登录',
translationNotice: '当前中文文档翻译不是最新版,访问英文版本查看最新内容,或', translationNotice: '当前中文文档翻译不是最新版,访问英文版本查看最新内容,或',
@@ -56,7 +56,7 @@ const strings: Record<string, Strings> = {
'zh-tw': { 'zh-tw': {
versionLabel: '版本', versionLabel: '版本',
languageLabel: '語言', languageLabel: '語言',
products: { docs: '文件', api: 'API', runner: 'Runner', enterprise: '企業版' }, products: { docs: '文件', api: 'API', runner: 'Runner', cli: 'CLI', enterprise: '企業版' },
footer: { Community: '社區', Code: '開源程式碼', More: '更多' }, footer: { Community: '社區', Code: '開源程式碼', More: '更多' },
signIn: '登入', signIn: '登入',
translationNotice: '當前中文文檔翻譯不是最新版,訪問英文版本查看最新內容,或', translationNotice: '當前中文文檔翻譯不是最新版,訪問英文版本查看最新內容,或',
+8 -8
View File
@@ -12,9 +12,9 @@ import { getCollection } from 'astro:content';
import { repoRoot } from '../config/paths'; import { repoRoot } from '../config/paths';
/** /**
* Sidebar files: the top level order of the docs and the * Sidebar files: the top level order of the docs and the handwritten sidebars
* handwritten sidebars of the runner, one per version. Read as is, so cutting a * of the runner and the cli, one per version. Read as is, so cutting a version
* version keeps needing no change here. * keeps needing no change here.
*/ */
function sidebarSources(): Map<string, SidebarSourceItem[]> { function sidebarSources(): Map<string, SidebarSourceItem[]> {
const sidebars = new Map<string, SidebarSourceItem[]>(); const sidebars = new Map<string, SidebarSourceItem[]>();
@@ -42,18 +42,18 @@ function sidebarSources(): Map<string, SidebarSourceItem[]> {
version.id === 'next' version.id === 'next'
? 'sidebars.js' ? 'sidebars.js'
: `versioned_sidebars/version-${version.id}-sidebars.json`; : `versioned_sidebars/version-${version.id}-sidebars.json`;
} else if (product.id === 'runner') { } else if (product.id === 'runner' || product.id === 'cli') {
files[`runner@${version.id}`] = files[`${product.id}@${version.id}`] =
version.id === 'develop' version.id === 'develop'
? 'runner-sidebars.js' ? `${product.id}-sidebars.js`
: `runner-docs_versioned_sidebars/version-${version.id}-sidebars.json`; : `${product.id}-docs_versioned_sidebars/version-${version.id}-sidebars.json`;
} }
} }
} }
for (const [key, file] of Object.entries(files)) { for (const [key, file] of Object.entries(files)) {
const config = read(file); 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); if (items) sidebars.set(key, items);
} }
return sidebars; 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. * per version sidebars, the version and language pickers and the search facets.
*/ */
export const giteaMeta = z.object({ export const giteaMeta = z.object({
product: z.enum(['docs', 'api', 'runner', 'enterprise']), product: z.enum(['docs', 'api', 'runner', 'cli', 'enterprise']),
version: z.string(), version: z.string(),
locale: z.string(), locale: z.string(),
prefix: z.string(), prefix: z.string(),