mirror of
https://gitea.com/gitea/docs.git
synced 2026-09-17 19:55:34 +00:00
Rebuild the site with Astro and Starlight (#496)
Replace [#307](https://gitea.com/gitea/docs/issues/307) Closes [#237](https://gitea.com/gitea/docs/issues/237) ## Summary Rebuilds docs.gitea.com with [Astro](https://astro.build) and [Starlight](https://starlight.astro.build), replacing Docusaurus. Every published url keeps working and no content file was moved: the site is a new rendering layer over the existing `docs/`, `versioned_docs/`, `i18n/`, `runner-docs/` and `static/swagger-*.json` trees. For the first time the API reference is part of the site rather than a Redoc bundle: all seven swagger documents are rendered into real pages, one per operation, so they are linkable, crawlable and searchable. ## Why - the Docusaurus build needs an 8 GB heap and about 2 minutes for 2478 pages; the Astro build produces 5751 pages, API reference included, in about 100 seconds - the API reference was a single client rendered Redoc page per version, absent from the site search and from search engines - sidebars, version lists and language lists were configured in three different places and drifted apart ## What is in here - **`packages/content-loader`** — the product × version × language matrix (`products.ts`) and an Astro content loader that reads the existing markdown trees directly. The matrix is the single source of truth: content loading, sidebars, the version and language pickers, the version banner, the search facets and the API schemas are all derived from it. - **Content compatibility** — frontmatter `slug`, `sidebar_position` and `sidebar_label` keep working, `_category_.json` still drives sidebar labels, order and the generated category pages, `@version@` style release variables are still substituted, `:::note` admonitions become Starlight asides and the 2778 relative `*.md` links are rewritten to urls while the site is built. - **API reference** — `starlight-openapi` renders the seven swagger documents. Operation urls are hyphenated (`/api/operations/list-admin-workflow-jobs/`), the sidebar shows the operations of the version being read with a coloured HTTP method badge, and the overview page links to one page per tag instead of repeating all 484 operations. - **Navigation** — a version picker that follows the reader to the same page in another version, a language picker that only offers the languages the current product is published in, and product links for Docs, API, Runner and Enterprise. - **Theme** — the light and dark palettes of about.gitea.com, mapped onto the Starlight variables. - **Search** — Pagefind by default; Algolia DocSearch takes over when `PUBLIC_DOCSEARCH_APP_ID` and `PUBLIC_DOCSEARCH_API_KEY` are set. Every page carries `docsearch:product`, `docsearch:version` and `docsearch:language` meta tags, so a search stays inside what is being read. `cloudflare/docsearch-crawler.json` holds the crawler configuration. ## Bugs fixed along the way - `GET /user/applications/oauth2` and `GET /user/applications/oauth2/{id}` have operation ids that only differ in case, so they collapsed onto the same url and one of the two pages was silently dropped. They are now `user-get-oauth2-application` and `user-get-oauth2-application-by-id`. - The API overview and every tag page were all titled "Overview". ## Url compatibility The routes of both builds were compared page by page during the migration with `scripts/url-diff.mjs`: ``` identical: 2368, missing: 0, accepted: 110, added: 3292 ``` The 110 accepted ones are all redirected in `cloudflare/_redirects`: the localized copies of the English-only API and Runner docs, the Docusaurus search page, and `/1.27/`, `/runner/3/`, `/api/1.27/` which are aliases of the versions served at the product root. The added ones are the API operation and tag pages plus the routes the Starlight language fallback serves in English when a translation is missing — Docusaurus answered those with a 404. ## Workflows - `checks` builds the site and type checks it on every pull request - `Build and Publish Docs site` publishes `sites/docs/dist` to S3/CloudFront and to Cloudflare Pages, and copies `cloudflare/_headers` and `cloudflare/_redirects` into the deployment - `update swagger files` and `update runner reference` are unchanged, they only touch content - `make cut-version PRODUCT=docs VERSION=1.28` replaces `docusaurus docs:version` ## Removed `docusaurus.config.js`, the swizzled theme under `src/`, and the Docusaurus UI translations (`i18n/*/code.json`, `i18n/*/docusaurus-theme-classic/`). The strings those carried — the product names, the footer column titles and the outdated translation notice — were ported to `sites/docs/src/config/strings.ts`; Starlight ships the rest of its interface in both Chinese locales. The documentation content itself is untouched. ## Follow ups - nine relative links are broken in the sources and reported by every build, the same ones Docusaurus warned about; `GITEA_DOCS_STRICT_LINKS=true` turns them into an error once they are fixed - `cloudflare/worker.js` has to be deployed for `/enterprise/` to keep resolving - the Algolia index has to be created and crawled before the search credentials are set ## Testing ```shell make install make serve-fast # english, the version served at the root make serve # the whole matrix make build # 5751 pages make check # 0 errors make serve-built # build and serve, the only way to try the search locally ``` ## Screenshots <img width="1371" alt="image.png" src="attachments/69acdd77-cc89-4635-a8bd-1163a34afa86"> <img width="1810" alt="image.png" src="attachments/76212416-4825-4b4d-bf59-3e549900c96f"> <img width="1789" alt="image.png" src="attachments/7be84cda-5abe-48bd-8839-7ae1ee7806e7"> --------- Co-authored-by: bircni <[email protected]> Reviewed-on: https://gitea.com/gitea/docs/pulls/496 Reviewed-by: bircni <[email protected]>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@gitea-docs/content-loader",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./products": "./src/products.ts",
|
||||
"./segments": "./src/segments.ts",
|
||||
"./sidebar": "./src/sidebar.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"tinyglobby": "0.2.15",
|
||||
"yaml": "2.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"astro": "^7.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Segment } from './segments.js';
|
||||
|
||||
/** `_category_.json`, the docusaurus per directory sidebar metadata. */
|
||||
export interface CategoryMeta {
|
||||
label?: string;
|
||||
position?: number;
|
||||
collapsed?: boolean;
|
||||
link?: {
|
||||
type?: string;
|
||||
slug?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const categoryFile = '_category_.json';
|
||||
|
||||
export async function readCategoryMeta(dir: string): Promise<CategoryMeta | undefined> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(path.join(dir, categoryFile), 'utf-8')) as CategoryMeta;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback label for a directory without `_category_.json`. */
|
||||
export function labelFromDirname(name: string): string {
|
||||
return name
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function slugifyLabel(label: string): string {
|
||||
return label
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Route id of the index page docusaurus generates for a category.
|
||||
*
|
||||
* Top level categories are declared by `sidebars.js` without a slug and end up
|
||||
* under `/category/<label>`; nested ones keep the slug of their
|
||||
* `_category_.json`. Both are reproduced so the existing urls keep working.
|
||||
*/
|
||||
export function categoryRouteId(
|
||||
segment: Segment,
|
||||
relativeDir: string,
|
||||
/** Metadata of the default language, translations do not move a category. */
|
||||
meta: CategoryMeta | undefined,
|
||||
): string | undefined {
|
||||
const isTopLevel = !relativeDir.includes('/');
|
||||
const label = meta?.label ?? labelFromDirname(relativeDir.split('/').at(-1) ?? relativeDir);
|
||||
if (isTopLevel) {
|
||||
return [segment.prefix, 'category', slugifyLabel(label)].filter(Boolean).join('/');
|
||||
}
|
||||
if (meta?.link?.type !== 'generated-index') return undefined;
|
||||
const slug = (meta.link.slug ?? `/${relativeDir}`).replace(/^\/+|\/+$/g, '');
|
||||
return [segment.prefix, slug].filter(Boolean).join('/');
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import type { Loader, LoaderContext } from 'astro/loaders';
|
||||
import { glob as tinyglob } from 'tinyglobby';
|
||||
import {
|
||||
categoryRouteId,
|
||||
labelFromDirname,
|
||||
readCategoryMeta,
|
||||
type CategoryMeta,
|
||||
} from './categories.js';
|
||||
import { internalLinks, rewriteLinks } from './links.js';
|
||||
import {
|
||||
applyVariables,
|
||||
extractTitle,
|
||||
normalizeAdmonitions,
|
||||
splitFrontmatter,
|
||||
} from './markdown.js';
|
||||
import {
|
||||
defaultLocale,
|
||||
resolveSegments,
|
||||
routeIdOf,
|
||||
routeIdToPath,
|
||||
routeSuffix,
|
||||
scopeFromEnv,
|
||||
segmentOf,
|
||||
type ScopeFilter,
|
||||
type Segment,
|
||||
} from './segments.js';
|
||||
|
||||
export * from './categories.js';
|
||||
export * from './links.js';
|
||||
export * from './markdown.js';
|
||||
export * from './products.js';
|
||||
export * from './segments.js';
|
||||
export * from './sidebar.js';
|
||||
|
||||
export interface GiteaDocsLoaderOptions {
|
||||
/** Repository root, the directory holding `docs/`, `versioned_docs/`, ... */
|
||||
root: string | URL;
|
||||
/** Restricts the loaded matrix, defaults to the environment scope. */
|
||||
scope?: ScopeFilter;
|
||||
/** Fails the build on a broken internal link instead of warning. */
|
||||
strictLinks?: boolean;
|
||||
}
|
||||
|
||||
/** A page read from disk, before it is rendered. */
|
||||
interface ParsedPage {
|
||||
segment: Segment;
|
||||
/** Path of the source file, relative to the repository root. */
|
||||
file: string;
|
||||
/** Path of the source file, relative to the version directory. */
|
||||
relative: string;
|
||||
/** Directory of the source file, relative to the version directory. */
|
||||
dir: string;
|
||||
id: string;
|
||||
title: string;
|
||||
order?: number;
|
||||
description?: string;
|
||||
tableOfContents: boolean;
|
||||
sidebarLabel?: string;
|
||||
body: string;
|
||||
digest: string;
|
||||
}
|
||||
|
||||
const extensions = ['md', 'mdx'];
|
||||
const concurrency = 8;
|
||||
/** Path starlight expects the docs collection to live at. */
|
||||
const collectionRoot = 'src/content/docs';
|
||||
const sourceRepo = 'https://gitea.com/gitea/docs/src/branch/main';
|
||||
const awesomeRepo = 'https://gitea.com/gitea/awesome-gitea/src/branch/main/README.md';
|
||||
/** Bumped when a content transform changes, so cached entries are rebuilt. */
|
||||
const transformVersion = '2';
|
||||
|
||||
/**
|
||||
* Loads the gitea documentation straight out of the content directories, which
|
||||
* keep the layout the docusaurus site used, instead of copying them into
|
||||
* `src/content/docs`. Every source file is mapped to its final route by the
|
||||
* product matrix, so the release and translation workflows are untouched.
|
||||
*/
|
||||
export function giteaDocsLoader(options: GiteaDocsLoaderOptions): Loader {
|
||||
const root = typeof options.root === 'string' ? options.root : fileURLToPath(options.root);
|
||||
const segments = resolveSegments(options.scope ?? scopeFromEnv());
|
||||
const strictLinks = options.strictLinks ?? process.env.GITEA_DOCS_STRICT_LINKS === 'true';
|
||||
// a scoped build does not know the routes of the parts it left out, so links
|
||||
// between products are only checked when everything is loaded
|
||||
const checkKnownLinks = segments.length === resolveSegments().length;
|
||||
|
||||
return {
|
||||
name: 'gitea-docs-loader',
|
||||
async load(context) {
|
||||
const { logger, store, watcher } = context;
|
||||
if (segments.length === 0) logger.warn('no content segment matched the current scope');
|
||||
|
||||
const started = Date.now();
|
||||
const untouched = new Set(store.keys());
|
||||
|
||||
const patterns = segments.map(
|
||||
(segment) => `${segment.dir}/**/[^_]*.{${extensions.join(',')}}`,
|
||||
);
|
||||
const files = (await tinyglob(patterns, { cwd: root, dot: false })).sort();
|
||||
|
||||
// first pass: read every file, so links can be resolved to routes before
|
||||
// anything is rendered
|
||||
const parsed: ParsedPage[] = [];
|
||||
await forEach(files, async (file) => {
|
||||
const segment = segmentOf(segments, file);
|
||||
if (!segment) return;
|
||||
const page = await parseFile(context, root, segment, file);
|
||||
if (page) parsed.push(page);
|
||||
});
|
||||
|
||||
const routes = new Map<Segment, Map<string, string>>();
|
||||
const known = new Set<string>();
|
||||
for (const page of parsed) {
|
||||
let group = routes.get(page.segment);
|
||||
if (!group) routes.set(page.segment, (group = new Map()));
|
||||
group.set(page.relative, routeIdToPath(page.id));
|
||||
known.add(routeIdToPath(page.id));
|
||||
}
|
||||
|
||||
const categories = await resolveCategories(root, segments, parsed);
|
||||
for (const category of categories) known.add(routeIdToPath(category.id));
|
||||
|
||||
// second pass: normalize, resolve links and render
|
||||
let broken = 0;
|
||||
await forEach(parsed, async (page) => {
|
||||
broken += await renderPage(
|
||||
context,
|
||||
root,
|
||||
page,
|
||||
routes.get(page.segment)!,
|
||||
checkKnownLinks ? known : undefined,
|
||||
);
|
||||
untouched.delete(page.id);
|
||||
});
|
||||
|
||||
for (const category of categories) {
|
||||
await renderCategory(context, category);
|
||||
untouched.delete(category.id);
|
||||
}
|
||||
|
||||
for (const id of untouched) store.delete(id);
|
||||
|
||||
const message = `loaded ${store.keys().length} pages from ${
|
||||
segments.length
|
||||
} content directories in ${Date.now() - started}ms`;
|
||||
if (broken === 0) logger.info(message);
|
||||
else if (strictLinks) throw new Error(`${broken} broken internal links found`);
|
||||
else logger.warn(`${message}, ${broken} broken internal links`);
|
||||
|
||||
if (!watcher) return;
|
||||
// dev: a change reloads everything, the second pass needs the route table
|
||||
for (const segment of segments) watcher.add(path.join(root, segment.dir));
|
||||
const reload = async (changed: string) => {
|
||||
const file = path.relative(root, changed).split(path.sep).join('/');
|
||||
if (!/\.mdx?$/.test(file) || !segmentOf(segments, file)) return;
|
||||
await this.load(context);
|
||||
};
|
||||
watcher.on('change', reload);
|
||||
watcher.on('add', reload);
|
||||
watcher.on('unlink', reload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `task` over `items` with a bounded number of concurrent tasks. */
|
||||
async function forEach<T>(items: T[], task: (item: T) => Promise<void>): Promise<void> {
|
||||
let index = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: concurrency }, async () => {
|
||||
while (index < items.length) await task(items[index++]!);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads a source file and works out where it is served. */
|
||||
async function parseFile(
|
||||
context: LoaderContext,
|
||||
root: string,
|
||||
segment: Segment,
|
||||
file: string,
|
||||
): Promise<ParsedPage | undefined> {
|
||||
const { generateDigest, logger } = context;
|
||||
const contents = await fs.readFile(path.join(root, file), 'utf-8').catch((error: Error) => {
|
||||
logger.error(`error reading ${file}: ${error.message}`);
|
||||
return undefined;
|
||||
});
|
||||
if (contents === undefined) return undefined;
|
||||
|
||||
const substituted = applyVariables(contents, segment.version.variables);
|
||||
const { frontmatter, body: rawBody } = splitFrontmatter(substituted);
|
||||
// a translated `slug` moves the page, exactly like it did under docusaurus
|
||||
const id = routeIdOf(segment, routeSuffix(segment, file, asString(frontmatter.slug)));
|
||||
const { title: headingTitle, body } = extractTitle(rawBody);
|
||||
const title = asString(frontmatter.title) ?? headingTitle;
|
||||
if (!title) logger.warn(`no title found for ${file}`);
|
||||
|
||||
const relative = file.slice(segment.dir.length + 1);
|
||||
const dir = path.posix.dirname(relative);
|
||||
|
||||
return {
|
||||
segment,
|
||||
file,
|
||||
relative,
|
||||
dir: dir === '.' ? '' : dir,
|
||||
id,
|
||||
title: title ?? relative.replace(/\.mdx?$/, ''),
|
||||
order: typeof frontmatter.sidebar_position === 'number' ? frontmatter.sidebar_position : undefined,
|
||||
description: asString(frontmatter.description),
|
||||
tableOfContents: frontmatter.toc !== false,
|
||||
sidebarLabel: asString(frontmatter.sidebar_label),
|
||||
body,
|
||||
digest: generateDigest(`${transformVersion}\u0000${substituted}`),
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalizes, resolves the links of and renders a page. Returns broken links. */
|
||||
async function renderPage(
|
||||
context: LoaderContext,
|
||||
root: string,
|
||||
page: ParsedPage,
|
||||
routes: Map<string, string>,
|
||||
known: Set<string> | undefined,
|
||||
): Promise<number> {
|
||||
const { logger, parseData, renderMarkdown, store } = context;
|
||||
const { segment } = page;
|
||||
|
||||
let broken = 0;
|
||||
const report = (target: string) => {
|
||||
broken += 1;
|
||||
logger.warn(`broken link to ${target} in ${page.file}`);
|
||||
};
|
||||
|
||||
let body = normalizeAdmonitions(page.body);
|
||||
body = rewriteLinks(body, {
|
||||
dir: page.dir,
|
||||
resolve: (source) => routes.get(source),
|
||||
onBroken: report,
|
||||
});
|
||||
|
||||
if (known) {
|
||||
for (const target of internalLinks(body)) {
|
||||
const [pathname] = target.split('#');
|
||||
if (pathname && !known.has(pathname) && !known.has(`${pathname}/`)) report(target);
|
||||
}
|
||||
}
|
||||
|
||||
const digest = context.generateDigest(`${page.digest}\u0000${broken}`);
|
||||
if (store.get(page.id)?.digest === digest) return broken;
|
||||
|
||||
// starlight resolves locale fallbacks through the path of the entry inside
|
||||
// `src/content/docs`, which the sources do not have. Give every entry a
|
||||
// synthetic one matching its route and point the edit link at the real file.
|
||||
const filePath = `${collectionRoot}/${page.id}.md`;
|
||||
const data = await parseData({
|
||||
id: page.id,
|
||||
filePath,
|
||||
data: {
|
||||
title: page.title,
|
||||
editUrl: editUrlOf(page.file),
|
||||
...(page.description ? { description: page.description } : {}),
|
||||
...(page.tableOfContents ? {} : { tableOfContents: false }),
|
||||
gitea: {
|
||||
product: segment.product.id,
|
||||
version: segment.version.id,
|
||||
locale: segment.locale,
|
||||
prefix: segment.prefix,
|
||||
dir: page.dir,
|
||||
name: path.posix.basename(page.relative).replace(/\.mdx?$/, ''),
|
||||
...(page.order === undefined ? {} : { order: page.order }),
|
||||
},
|
||||
sidebar: {
|
||||
...(page.order === undefined ? {} : { order: page.order }),
|
||||
...(page.sidebarLabel ? { label: page.sidebarLabel } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rendered = await renderMarkdown(body, {
|
||||
fileURL: pathToFileURL(path.join(root, page.file)),
|
||||
});
|
||||
store.set({ id: page.id, data, body, filePath, digest, rendered });
|
||||
return broken;
|
||||
}
|
||||
|
||||
/** The index page docusaurus generated for a category. */
|
||||
interface ParsedCategory {
|
||||
segment: Segment;
|
||||
id: string;
|
||||
dir: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
order?: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreates the index pages docusaurus generated from `_category_.json`, so
|
||||
* that `/category/installation/` and `/usage/actions/` keep resolving.
|
||||
*/
|
||||
async function resolveCategories(
|
||||
root: string,
|
||||
segments: Segment[],
|
||||
pages: ParsedPage[],
|
||||
): Promise<ParsedCategory[]> {
|
||||
const categories: ParsedCategory[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
if (!segment.product.categoryPages) continue;
|
||||
const segmentPages = pages.filter((page) => page.segment === segment);
|
||||
|
||||
const directories = new Set<string>();
|
||||
for (const page of segmentPages) {
|
||||
const parts = page.dir ? page.dir.split('/') : [];
|
||||
for (let depth = 1; depth <= parts.length; depth += 1) {
|
||||
directories.add(parts.slice(0, depth).join('/'));
|
||||
}
|
||||
}
|
||||
|
||||
// labels come from the language of the segment, routes from the english tree
|
||||
const metas = new Map<string, CategoryMeta | undefined>();
|
||||
const routeMetas = new Map<string, CategoryMeta | undefined>();
|
||||
const englishDir = segment.version.sources[defaultLocale] ?? segment.dir;
|
||||
await Promise.all(
|
||||
[...directories].map(async (dir) => {
|
||||
metas.set(dir, await readCategoryMeta(path.join(root, segment.dir, dir)));
|
||||
routeMetas.set(
|
||||
dir,
|
||||
segment.locale === defaultLocale
|
||||
? metas.get(dir)
|
||||
: await readCategoryMeta(path.join(root, englishDir, dir)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const label = (dir: string) =>
|
||||
metas.get(dir)?.label ?? labelFromDirname(dir.split('/').at(-1) ?? dir);
|
||||
|
||||
for (const dir of [...directories].sort()) {
|
||||
const meta = metas.get(dir);
|
||||
const id = categoryRouteId(segment, dir, routeMetas.get(dir));
|
||||
if (!id) continue;
|
||||
|
||||
const childDirs = [...directories]
|
||||
.filter((candidate) => path.posix.dirname(candidate) === dir)
|
||||
.sort((a, b) => (metas.get(a)?.position ?? 0) - (metas.get(b)?.position ?? 0));
|
||||
const childPages = segmentPages
|
||||
.filter((page) => page.dir === dir)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const links: string[] = [];
|
||||
for (const child of childDirs) {
|
||||
const childId = categoryRouteId(segment, child, routeMetas.get(child));
|
||||
if (childId) links.push(`- [${label(child)}](${routeIdToPath(childId)})`);
|
||||
}
|
||||
for (const page of childPages) links.push(`- [${page.title}](${routeIdToPath(page.id)})`);
|
||||
|
||||
const description = meta?.link?.description;
|
||||
categories.push({
|
||||
segment,
|
||||
id,
|
||||
dir,
|
||||
title: meta?.link?.title ?? label(dir),
|
||||
description,
|
||||
order: meta?.position,
|
||||
body: [description, links.join('\n')].filter(Boolean).join('\n\n'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
async function renderCategory(context: LoaderContext, category: ParsedCategory): Promise<void> {
|
||||
const { generateDigest, parseData, renderMarkdown, store } = context;
|
||||
const digest = generateDigest(`${transformVersion}\u0000${category.title}\u0000${category.body}`);
|
||||
if (store.get(category.id)?.digest === digest) return;
|
||||
|
||||
const filePath = `${collectionRoot}/${category.id}.md`;
|
||||
const data = await parseData({
|
||||
id: category.id,
|
||||
filePath,
|
||||
data: {
|
||||
title: category.title,
|
||||
...(category.description ? { description: category.description } : {}),
|
||||
editUrl: false,
|
||||
gitea: {
|
||||
product: category.segment.product.id,
|
||||
version: category.segment.version.id,
|
||||
locale: category.segment.locale,
|
||||
prefix: category.segment.prefix,
|
||||
dir: path.posix.dirname(category.dir) === '.' ? '' : path.posix.dirname(category.dir),
|
||||
name: category.dir.split('/').at(-1)!,
|
||||
category: true,
|
||||
...(category.order === undefined ? {} : { order: category.order }),
|
||||
},
|
||||
sidebar: { hidden: true },
|
||||
},
|
||||
});
|
||||
|
||||
store.set({
|
||||
id: category.id,
|
||||
data,
|
||||
body: category.body,
|
||||
filePath,
|
||||
digest,
|
||||
rendered: await renderMarkdown(category.body),
|
||||
});
|
||||
}
|
||||
|
||||
/** Edit link of a source file, relative to the repository root. */
|
||||
function editUrlOf(file: string): string {
|
||||
return file.endsWith('/awesome.md') ? awesomeRepo : `${sourceRepo}/${file}`;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Rewrites the relative markdown links of the sources to the urls of the built
|
||||
* site. Docusaurus resolved `../administration/config-cheat-sheet.md` against
|
||||
* the directory of the file it appeared in; astro serves urls, so the target
|
||||
* has to be looked up in the route table of the version.
|
||||
*/
|
||||
|
||||
export interface LinkContext {
|
||||
/** Directory of the source file, relative to the version directory. */
|
||||
dir: string;
|
||||
/** Route of a source path, relative to the version directory. */
|
||||
resolve(sourcePath: string): string | undefined;
|
||||
/** Called for every link that cannot be resolved. */
|
||||
onBroken(target: string): void;
|
||||
}
|
||||
|
||||
const markdownLink = /(\]\()([^)\s]+)((?:\s+"[^"]*")?\))/g;
|
||||
const referenceLink = /^(\s*\[[^\]]+\]:\s*)(\S+)(.*)$/gm;
|
||||
|
||||
/** Normalizes `a/b/../c` without resorting to the node path module. */
|
||||
function normalize(pathname: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const part of pathname.split('/')) {
|
||||
if (part === '' || part === '.') continue;
|
||||
if (part === '..') parts.pop();
|
||||
else parts.push(part);
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function rewriteTarget(target: string, context: LinkContext): string {
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//') || target.startsWith('#')) {
|
||||
return target;
|
||||
}
|
||||
const [pathname = '', hash = ''] = splitHash(target);
|
||||
if (!/\.mdx?$/.test(pathname)) return target;
|
||||
|
||||
// docusaurus resolved a link against the directory of the file first and
|
||||
// against the root of the version second, both spellings are in the sources
|
||||
const route =
|
||||
context.resolve(normalize(`${context.dir}/${pathname}`)) ?? context.resolve(normalize(pathname));
|
||||
if (!route) {
|
||||
context.onBroken(target);
|
||||
return target;
|
||||
}
|
||||
return `${route}${hash}`;
|
||||
}
|
||||
|
||||
function splitHash(target: string): [string, string] {
|
||||
const index = target.indexOf('#');
|
||||
return index === -1 ? [target, ''] : [target.slice(0, index), target.slice(index)];
|
||||
}
|
||||
|
||||
export function rewriteLinks(body: string, context: LinkContext): string {
|
||||
return body
|
||||
.replace(markdownLink, (_match, open: string, target: string, close: string) =>
|
||||
`${open}${rewriteTarget(target, context)}${close}`,
|
||||
)
|
||||
.replace(referenceLink, (_match, open: string, target: string, rest: string) =>
|
||||
`${open}${rewriteTarget(target, context)}${rest}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal page links of a page, used by the link checker. Links to a file
|
||||
* (images, archives, ...) are left out, they are served from `public/`.
|
||||
*/
|
||||
export function internalLinks(body: string): string[] {
|
||||
const found: string[] = [];
|
||||
for (const match of body.matchAll(markdownLink)) {
|
||||
const target = match[2] ?? '';
|
||||
if (!target.startsWith('/') || target.startsWith('//')) continue;
|
||||
const [pathname = ''] = target.split('#');
|
||||
if (pathname.split('/').at(-1)?.includes('.')) continue;
|
||||
found.push(target);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import type { VersionVariables } from './products.js';
|
||||
|
||||
export interface ParsedFile {
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const frontmatterPattern = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
||||
|
||||
export function splitFrontmatter(contents: string): ParsedFile {
|
||||
const match = contents.match(frontmatterPattern);
|
||||
if (!match) return { frontmatter: {}, body: contents };
|
||||
const parsed = parseYaml(match[1] ?? '') as Record<string, unknown> | null;
|
||||
return { frontmatter: parsed ?? {}, body: contents.slice(match[0].length) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the `@name@` placeholders with the values of the version, the same
|
||||
* substitution the docusaurus markdown preprocessor did.
|
||||
*/
|
||||
export function applyVariables(contents: string, variables?: VersionVariables): string {
|
||||
if (!variables) return contents;
|
||||
let result = contents;
|
||||
for (const [name, value] of Object.entries(variables)) {
|
||||
result = result.replaceAll(`@${name}@`, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starlight renders the page title from the frontmatter, the gitea sources
|
||||
* carry it as a leading level one heading instead. Lift it into the
|
||||
* frontmatter and drop it from the body so it is not rendered twice.
|
||||
*/
|
||||
export function extractTitle(body: string): { title?: string; body: string } {
|
||||
// the first level one heading is the title, wherever it sits: a few pages
|
||||
// open with an admonition before it
|
||||
const match = body.match(/^#\s+(.+?)\s*$/m);
|
||||
if (!match || match.index === undefined) return { body };
|
||||
return {
|
||||
title: match[1],
|
||||
body: body.slice(0, match.index) + body.slice(match.index + match[0].length),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Docusaurus admonitions to starlight asides. The two vocabularies overlap for
|
||||
* `note`, `tip`, `caution` and `danger`; `warning` and `info` have to be mapped
|
||||
* and titles move from `:::note Title` to `:::note[Title]`.
|
||||
*/
|
||||
const asideTypes: Record<string, string> = {
|
||||
note: 'note',
|
||||
tip: 'tip',
|
||||
info: 'note',
|
||||
warning: 'caution',
|
||||
caution: 'caution',
|
||||
danger: 'danger',
|
||||
};
|
||||
|
||||
export function normalizeAdmonitions(body: string): string {
|
||||
return body
|
||||
.split('\n')
|
||||
.flatMap((line) => {
|
||||
const match = line.match(/^(\s*):::([a-z]+)(?:\[([^\]]*)\])?[ \t]*(.*)$/);
|
||||
if (!match) return [line];
|
||||
const [, indent = '', rawType = '', bracketTitle, rest = ''] = match;
|
||||
const type = asideTypes[rawType];
|
||||
if (!type) return [line];
|
||||
|
||||
// `:::warning some text:::` appears in a few pages, split it up
|
||||
const inline = rest.endsWith(':::') ? rest.slice(0, -3).trim() : undefined;
|
||||
const title = bracketTitle ?? (inline === undefined ? rest.trim() : '');
|
||||
const opening = `${indent}:::${type}${title ? `[${title}]` : ''}`;
|
||||
if (inline === undefined) return [opening];
|
||||
return inline ? [opening, `${indent}${inline}`, `${indent}:::`] : [opening, `${indent}:::`];
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Single source of truth for the product x version x language matrix.
|
||||
*
|
||||
* Everything else (content loading, sidebars, the version and language
|
||||
* pickers, search facets and the sitemap) is derived from this file, so
|
||||
* adding a version or a language is a one line change here.
|
||||
*/
|
||||
|
||||
export type LocaleId = 'en-us' | 'zh-cn' | 'zh-tw';
|
||||
|
||||
export type ProductId = 'docs' | 'api' | 'runner' | 'enterprise';
|
||||
|
||||
/** Values substituted for `@name@` placeholders in the markdown sources. */
|
||||
export interface VersionVariables {
|
||||
goVersion: string;
|
||||
minGoVersion: string;
|
||||
minNodeVersion: string;
|
||||
version: string;
|
||||
sourceVersion: string;
|
||||
sourceBranch: string;
|
||||
dockerVersion: string;
|
||||
displayVersion: string;
|
||||
}
|
||||
|
||||
export interface VersionDef {
|
||||
/** Stable identifier, e.g. `1.27`, `next`, `3`, `develop`. */
|
||||
id: string;
|
||||
/** Label shown in the version picker. */
|
||||
label: string;
|
||||
/**
|
||||
* Route segment inserted after the product base. Empty for the version
|
||||
* served at the product root (docs 1.27 at `/`, runner 3 at `/runner/`).
|
||||
*/
|
||||
path: string;
|
||||
/** Marks the version served at the product root. */
|
||||
latest?: boolean;
|
||||
/** Starlight banner shown on every page of the version. */
|
||||
banner?: 'unreleased' | 'unmaintained';
|
||||
/** Content directories, relative to the repository root, per language. */
|
||||
sources: Partial<Record<LocaleId, string>>;
|
||||
/** OpenAPI document, relative to the repository root (api product only). */
|
||||
schema?: string;
|
||||
variables?: VersionVariables;
|
||||
}
|
||||
|
||||
export interface ProductDef {
|
||||
id: ProductId;
|
||||
label: string;
|
||||
/** Route segment shared by every version, empty for the docs product. */
|
||||
base: string;
|
||||
/** Languages the product is published in, in display order. */
|
||||
locales: LocaleId[];
|
||||
versions: VersionDef[];
|
||||
/** Recreate the docusaurus `_category_.json` index pages for the product. */
|
||||
categoryPages?: boolean;
|
||||
/** Set for products served by another deployment (enterprise). */
|
||||
externalBaseUrl?: string;
|
||||
}
|
||||
|
||||
export const locales: Record<LocaleId, { label: string; lang: string }> = {
|
||||
'en-us': { label: 'English', lang: 'en-US' },
|
||||
'zh-cn': { label: '简体中文', lang: 'zh-CN' },
|
||||
'zh-tw': { label: '繁體中文', lang: 'zh-TW' },
|
||||
};
|
||||
|
||||
export const defaultLocale: LocaleId = 'en-us';
|
||||
|
||||
const variables: Record<string, VersionVariables> = {
|
||||
next: {
|
||||
goVersion: '1.26',
|
||||
minGoVersion: '1.26',
|
||||
minNodeVersion: '22',
|
||||
version: 'main-nightly',
|
||||
sourceVersion: 'main',
|
||||
sourceBranch: 'main',
|
||||
dockerVersion: 'nightly',
|
||||
displayVersion: '1.28-dev',
|
||||
},
|
||||
'1.27': {
|
||||
goVersion: '1.26',
|
||||
minGoVersion: '1.26',
|
||||
minNodeVersion: '24',
|
||||
version: '1.27.1',
|
||||
sourceVersion: 'v1.27.1',
|
||||
sourceBranch: 'release/v1.27',
|
||||
dockerVersion: '1.27.1',
|
||||
displayVersion: '1.27.1',
|
||||
},
|
||||
'1.26': {
|
||||
goVersion: '1.26',
|
||||
minGoVersion: '1.26',
|
||||
minNodeVersion: '22',
|
||||
version: '1.26.4',
|
||||
sourceVersion: 'v1.26.4',
|
||||
sourceBranch: 'release/v1.26',
|
||||
dockerVersion: '1.26.4',
|
||||
displayVersion: '1.26.4',
|
||||
},
|
||||
'1.25': {
|
||||
goVersion: '1.25',
|
||||
minGoVersion: '1.25',
|
||||
minNodeVersion: '22',
|
||||
version: '1.25.5',
|
||||
sourceVersion: 'v1.25.0',
|
||||
sourceBranch: 'release/v1.25',
|
||||
dockerVersion: '1.25.5',
|
||||
displayVersion: '1.25.5',
|
||||
},
|
||||
'1.24': {
|
||||
goVersion: '1.24',
|
||||
minGoVersion: '1.24',
|
||||
minNodeVersion: '22',
|
||||
version: '1.24.7',
|
||||
sourceVersion: 'v1.24.0',
|
||||
sourceBranch: 'release/v1.24',
|
||||
dockerVersion: '1.24.7',
|
||||
displayVersion: '1.24.7',
|
||||
},
|
||||
'1.23': {
|
||||
goVersion: '1.23',
|
||||
minGoVersion: '1.22',
|
||||
minNodeVersion: '18',
|
||||
version: '1.23.8',
|
||||
sourceVersion: 'v1.23.8',
|
||||
sourceBranch: 'release/v1.23',
|
||||
dockerVersion: '1.23.8',
|
||||
displayVersion: '1.23.8',
|
||||
},
|
||||
'1.22': {
|
||||
goVersion: '1.22',
|
||||
minGoVersion: '1.22',
|
||||
minNodeVersion: '18',
|
||||
version: '1.22.6',
|
||||
sourceVersion: 'v1.22.6',
|
||||
sourceBranch: 'release/v1.22',
|
||||
dockerVersion: '1.22.6',
|
||||
displayVersion: '1.22.6',
|
||||
},
|
||||
};
|
||||
|
||||
/** Localized content of the gitea docs, as laid out by the docusaurus tree. */
|
||||
function docsSources(version: 'current' | string): VersionDef['sources'] {
|
||||
const dir = version === 'current' ? 'docs' : `versioned_docs/version-${version}`;
|
||||
const i18nDir = (locale: LocaleId) =>
|
||||
`i18n/${locale}/docusaurus-plugin-content-docs/${
|
||||
version === 'current' ? 'current' : `version-${version}`
|
||||
}`;
|
||||
return {
|
||||
'en-us': dir,
|
||||
'zh-cn': i18nDir('zh-cn'),
|
||||
'zh-tw': i18nDir('zh-tw'),
|
||||
};
|
||||
}
|
||||
|
||||
const docsProduct: ProductDef = {
|
||||
id: 'docs',
|
||||
label: 'Docs',
|
||||
base: '',
|
||||
locales: ['en-us', 'zh-cn', 'zh-tw'],
|
||||
categoryPages: true,
|
||||
versions: [
|
||||
{
|
||||
id: 'next',
|
||||
label: variables.next.displayVersion,
|
||||
path: 'next',
|
||||
banner: 'unreleased',
|
||||
sources: docsSources('current'),
|
||||
variables: variables.next,
|
||||
},
|
||||
{
|
||||
id: '1.27',
|
||||
label: variables['1.27'].displayVersion,
|
||||
path: '',
|
||||
latest: true,
|
||||
sources: docsSources('1.27'),
|
||||
variables: variables['1.27'],
|
||||
},
|
||||
...['1.26', '1.25', '1.24', '1.23', '1.22'].map(
|
||||
(id): VersionDef => ({
|
||||
id,
|
||||
label: variables[id]!.displayVersion,
|
||||
path: id,
|
||||
sources: docsSources(id),
|
||||
variables: variables[id],
|
||||
}),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** Runner docs: one directory per release series, english only for now. */
|
||||
const runnerSeries = ['3', '2', '1', '0'];
|
||||
|
||||
const runnerProduct: ProductDef = {
|
||||
id: 'runner',
|
||||
label: 'Runner',
|
||||
base: 'runner',
|
||||
locales: ['en-us'],
|
||||
versions: [
|
||||
{
|
||||
id: 'develop',
|
||||
label: 'develop',
|
||||
path: 'develop',
|
||||
banner: 'unreleased',
|
||||
sources: { 'en-us': 'runner-docs' },
|
||||
},
|
||||
...runnerSeries.map(
|
||||
(id, index): VersionDef => ({
|
||||
id,
|
||||
label: `${id}.x`,
|
||||
path: index === 0 ? '' : id,
|
||||
latest: index === 0,
|
||||
sources: { 'en-us': `runner-docs_versioned_docs/version-${id}` },
|
||||
}),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** API reference, rendered from the swagger documents by starlight-openapi. */
|
||||
const apiProduct: ProductDef = {
|
||||
id: 'api',
|
||||
label: 'API',
|
||||
base: 'api',
|
||||
locales: ['en-us'],
|
||||
versions: [
|
||||
{
|
||||
id: 'next',
|
||||
label: variables.next.displayVersion,
|
||||
path: 'next',
|
||||
banner: 'unreleased',
|
||||
sources: {},
|
||||
schema: 'static/swagger-latest.json',
|
||||
},
|
||||
{
|
||||
id: '1.27',
|
||||
label: variables['1.27'].displayVersion,
|
||||
path: '',
|
||||
latest: true,
|
||||
sources: {},
|
||||
schema: 'static/swagger-27.json',
|
||||
},
|
||||
...['1.26', '1.25', '1.24', '1.23', '1.22'].map(
|
||||
(id): VersionDef => ({
|
||||
id,
|
||||
label: variables[id]!.displayVersion,
|
||||
path: id,
|
||||
sources: {},
|
||||
schema: `static/swagger-${id.split('.')[1]}.json`,
|
||||
}),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** Served by a separate cloudflare pages project, linked from the header. */
|
||||
const enterpriseProduct: ProductDef = {
|
||||
id: 'enterprise',
|
||||
label: 'Enterprise',
|
||||
base: 'enterprise',
|
||||
locales: ['en-us', 'zh-cn'],
|
||||
versions: [],
|
||||
externalBaseUrl: 'https://docs.gitea.com/enterprise',
|
||||
};
|
||||
|
||||
export const products: ProductDef[] = [docsProduct, apiProduct, runnerProduct, enterpriseProduct];
|
||||
|
||||
export function getProduct(id: ProductId): ProductDef {
|
||||
const product = products.find((candidate) => candidate.id === id);
|
||||
if (!product) throw new Error(`unknown product: ${id}`);
|
||||
return product;
|
||||
}
|
||||
|
||||
export function getVersion(productId: ProductId, versionId: string): VersionDef {
|
||||
const version = getProduct(productId).versions.find((candidate) => candidate.id === versionId);
|
||||
if (!version) throw new Error(`unknown version: ${productId}@${versionId}`);
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route prefix of a (product, version, locale) triple, without leading or
|
||||
* trailing slash: `""`, `next`, `1.26`, `zh-cn/next`, `runner/2`.
|
||||
*
|
||||
* The docs are the site itself and keep the language in front, as starlight
|
||||
* expects. The other products are self contained, so their language goes inside
|
||||
* the product: `/runner/zh-cn/3/`, not `/zh-cn/runner/3/`. Only english is
|
||||
* published for them today, the layout is reserved for when that changes.
|
||||
*/
|
||||
export function routePrefix(product: ProductDef, version: VersionDef, locale: LocaleId): string {
|
||||
const language = locale === defaultLocale ? '' : locale;
|
||||
return product.base
|
||||
? [product.base, language, version.path].filter(Boolean).join('/')
|
||||
: [language, version.path].filter(Boolean).join('/');
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
defaultLocale,
|
||||
products,
|
||||
routePrefix,
|
||||
type LocaleId,
|
||||
type ProductDef,
|
||||
type ProductId,
|
||||
type VersionDef,
|
||||
} from './products.js';
|
||||
|
||||
/**
|
||||
* One content directory of the matrix: a (product, version, language) triple
|
||||
* mapped to its source directory and its route prefix.
|
||||
*/
|
||||
export interface Segment {
|
||||
product: ProductDef;
|
||||
version: VersionDef;
|
||||
locale: LocaleId;
|
||||
/** Source directory, relative to the repository root. */
|
||||
dir: string;
|
||||
/** Route prefix, without leading or trailing slash. */
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export interface ScopeFilter {
|
||||
products?: ProductId[];
|
||||
versions?: string[];
|
||||
locales?: LocaleId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the build scope from the environment. Used to build a subset of the
|
||||
* matrix during development, where loading every version is wasteful.
|
||||
*
|
||||
* GITEA_DOCS_PRODUCTS=docs,runner GITEA_DOCS_VERSIONS=1.27 pnpm dev
|
||||
*/
|
||||
export function scopeFromEnv(env: NodeJS.ProcessEnv = process.env): ScopeFilter {
|
||||
const list = (value: string | undefined) =>
|
||||
value
|
||||
?.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
products: list(env.GITEA_DOCS_PRODUCTS) as ProductId[] | undefined,
|
||||
versions: list(env.GITEA_DOCS_VERSIONS),
|
||||
locales: list(env.GITEA_DOCS_LOCALES) as LocaleId[] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Expands the product matrix into the content directories to load. */
|
||||
export function resolveSegments(filter: ScopeFilter = {}): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
for (const product of products) {
|
||||
if (product.externalBaseUrl) continue;
|
||||
if (filter.products && !filter.products.includes(product.id)) continue;
|
||||
for (const version of product.versions) {
|
||||
if (filter.versions && !filter.versions.includes(version.id)) continue;
|
||||
for (const locale of product.locales) {
|
||||
if (filter.locales && !filter.locales.includes(locale)) continue;
|
||||
const dir = version.sources[locale];
|
||||
if (!dir) continue;
|
||||
segments.push({
|
||||
product,
|
||||
version,
|
||||
locale,
|
||||
dir,
|
||||
prefix: routePrefix(product, version, locale),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Finds the segment a repository relative file path belongs to. */
|
||||
export function segmentOf(segments: Segment[], file: string): Segment | undefined {
|
||||
let match: Segment | undefined;
|
||||
for (const segment of segments) {
|
||||
if (!file.startsWith(`${segment.dir}/`)) continue;
|
||||
// longest directory wins, `docs` must not swallow `docs-foo`
|
||||
if (!match || segment.dir.length > match.dir.length) match = segment;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route id of a source file, mirroring the docusaurus routing rules:
|
||||
* `index` files address their directory, a relative `slug` replaces the last
|
||||
* path segment and `slug: /` addresses the root of the version.
|
||||
*/
|
||||
export function routeSuffix(segment: Segment, file: string, slug?: string): string[] {
|
||||
const relative = file.slice(segment.dir.length + 1).replace(/\.mdx?$/, '');
|
||||
const parts = relative.split('/');
|
||||
const isIndex = parts.at(-1) === 'index';
|
||||
if (isIndex) parts.pop();
|
||||
|
||||
if (slug === '/') {
|
||||
parts.length = 0;
|
||||
} else if (slug) {
|
||||
if (!isIndex) parts.pop();
|
||||
parts.push(...slug.replace(/^\/+|\/+$/g, '').split('/'));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route id of a source file. Translations follow the route of their english
|
||||
* counterpart, a translated `slug` never moves a page, which is how docusaurus
|
||||
* treated the localized trees as well.
|
||||
*/
|
||||
export function routeIdOf(segment: Segment, suffix: string[]): string {
|
||||
// the data store rejects empty ids, starlight maps `index` to the root
|
||||
return [segment.prefix, ...suffix].filter(Boolean).join('/') || 'index';
|
||||
}
|
||||
|
||||
/** Path of a source file relative to its segment directory. */
|
||||
export function relativeToSegment(segment: Segment, file: string): string {
|
||||
return file.slice(segment.dir.length + 1);
|
||||
}
|
||||
|
||||
/** Key identifying the (product, version) a segment translates. */
|
||||
export function groupKeyOf(segment: Segment): string {
|
||||
return `${segment.product.id}@${segment.version.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out which (product, version, language) a route belongs to. Used for the
|
||||
* pages starlight builds outside of the loader, the api reference in
|
||||
* particular, so they get the same pickers, banner and search facets.
|
||||
*/
|
||||
export function metaFromRouteId(id: string):
|
||||
| { product: ProductId; version: string; locale: LocaleId; prefix: string }
|
||||
| undefined {
|
||||
const route = id === 'index' ? '' : id;
|
||||
let best: { product: ProductId; version: string; locale: LocaleId; prefix: string } | undefined;
|
||||
for (const product of products) {
|
||||
if (product.externalBaseUrl) continue;
|
||||
for (const version of product.versions) {
|
||||
for (const locale of product.locales) {
|
||||
const prefix = routePrefix(product, version, locale);
|
||||
// the api pages are generated by starlight-openapi, which slugifies the
|
||||
// base path: `api/1.26` is built as `api/126` and renamed afterwards
|
||||
const candidates = [prefix, slugifyPrefix(prefix)];
|
||||
if (prefix && !candidates.some((candidate) => route === candidate || route.startsWith(`${candidate}/`))) {
|
||||
continue;
|
||||
}
|
||||
if (best && best.prefix.length >= prefix.length) continue;
|
||||
best = { product: product.id, version: version.id, locale, prefix };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Same slugification github-slugger applies to a starlight-openapi base path. */
|
||||
function slugifyPrefix(prefix: string): string {
|
||||
return prefix
|
||||
.split('/')
|
||||
.map((part) => part.toLowerCase().replace(/[^\w-]/g, ''))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
/** Url of a route id, mirroring the starlight slug to pathname rules. */
|
||||
export function routeIdToPath(id: string): string {
|
||||
if (id === '' || id === 'index') return '/';
|
||||
const trimmed = id.endsWith('/index') ? id.slice(0, -'/index'.length) : id;
|
||||
return `/${trimmed}/`;
|
||||
}
|
||||
|
||||
export { defaultLocale };
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Builds one sidebar per (product, version, language) out of the loaded pages,
|
||||
* reproducing what docusaurus derived from `_category_.json` and from the
|
||||
* handwritten sidebar files of the runner docs.
|
||||
*/
|
||||
|
||||
export interface SidebarPage {
|
||||
/** Route id of the page. */
|
||||
id: string;
|
||||
/** Url of the page. */
|
||||
href: string;
|
||||
title: string;
|
||||
hidden: boolean;
|
||||
meta: {
|
||||
product: string;
|
||||
version: string;
|
||||
locale: string;
|
||||
prefix: string;
|
||||
dir: string;
|
||||
name: string;
|
||||
order?: number;
|
||||
category?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SidebarLink {
|
||||
type: 'link';
|
||||
label: string;
|
||||
href: string;
|
||||
isCurrent: boolean;
|
||||
badge: undefined;
|
||||
attrs: Record<string, never>;
|
||||
}
|
||||
|
||||
export interface SidebarGroup {
|
||||
type: 'group';
|
||||
label: string;
|
||||
entries: SidebarEntry[];
|
||||
collapsed: boolean;
|
||||
badge: undefined;
|
||||
}
|
||||
|
||||
export type SidebarEntry = SidebarLink | SidebarGroup;
|
||||
|
||||
/** A docusaurus sidebar file, as used by the runner docs. */
|
||||
export type DocusaurusSidebarItem =
|
||||
| string
|
||||
| { type: 'doc'; id: string; label?: string }
|
||||
| { type: 'autogenerated'; dirName: string }
|
||||
| { type: 'category'; label: string; collapsed?: boolean; items: DocusaurusSidebarItem[] };
|
||||
|
||||
const last = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
function link(page: SidebarPage, label = page.title): SidebarLink {
|
||||
return { type: 'link', label, href: page.href, isCurrent: false, badge: undefined, attrs: {} };
|
||||
}
|
||||
|
||||
function labelFromDirname(name: string): string {
|
||||
return name
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the sidebars of every group of pages, keyed by the route prefix of the
|
||||
* group. `manual` provides the docusaurus sidebar of the groups that ship one.
|
||||
*/
|
||||
export function buildSidebars(
|
||||
pages: SidebarPage[],
|
||||
manual: Map<string, DocusaurusSidebarItem[]> = new Map(),
|
||||
): Map<string, SidebarEntry[]> {
|
||||
const groups = new Map<string, SidebarPage[]>();
|
||||
for (const page of pages) {
|
||||
const list = groups.get(page.meta.prefix);
|
||||
if (list) list.push(page);
|
||||
else groups.set(page.meta.prefix, [page]);
|
||||
}
|
||||
|
||||
const sidebars = new Map<string, SidebarEntry[]>();
|
||||
for (const [prefix, groupPages] of groups) {
|
||||
const key = `${groupPages[0]!.meta.product}@${groupPages[0]!.meta.version}`;
|
||||
const items = manual.get(key);
|
||||
const tree = treeOf(groupPages);
|
||||
sidebars.set(prefix, items ? fromDocusaurus(items, groupPages, tree) : tree.build(''));
|
||||
}
|
||||
return sidebars;
|
||||
}
|
||||
|
||||
interface Tree {
|
||||
/** Entries of a directory, ordered as docusaurus ordered them. */
|
||||
build(dir: string): SidebarEntry[];
|
||||
/** Localized label of a directory, from its `_category_.json`. */
|
||||
label(dir: string): string;
|
||||
}
|
||||
|
||||
/** Sidebar of a directory tree, ordered by `sidebar_position` as docusaurus did. */
|
||||
function treeOf(pages: SidebarPage[]): Tree {
|
||||
const categories = new Map<string, SidebarPage>();
|
||||
for (const page of pages) {
|
||||
if (!page.meta.category) continue;
|
||||
const dir = [page.meta.dir, page.meta.name].filter(Boolean).join('/');
|
||||
categories.set(dir, page);
|
||||
}
|
||||
|
||||
const childDirs = (dir: string) => {
|
||||
const prefix = dir ? `${dir}/` : '';
|
||||
const found = new Set<string>();
|
||||
for (const page of pages) {
|
||||
if (!page.meta.dir.startsWith(prefix)) continue;
|
||||
const rest = page.meta.dir.slice(prefix.length);
|
||||
if (!rest) continue;
|
||||
found.add(prefix + rest.split('/')[0]);
|
||||
}
|
||||
return [...found];
|
||||
};
|
||||
|
||||
const build = (dir: string): SidebarEntry[] => {
|
||||
const entries: { sort: [number, number, string]; entry: SidebarEntry }[] = [];
|
||||
|
||||
for (const child of childDirs(dir)) {
|
||||
const category = categories.get(child);
|
||||
const label = category?.title ?? labelFromDirname(child.split('/').at(-1)!);
|
||||
entries.push({
|
||||
sort: [category?.meta.order ?? last, 0, label],
|
||||
entry: { type: 'group', label, entries: build(child), collapsed: true, badge: undefined },
|
||||
});
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
if (page.meta.dir !== dir || page.meta.category || page.hidden) continue;
|
||||
entries.push({ sort: [page.meta.order ?? last, 1, page.title], entry: link(page) });
|
||||
}
|
||||
|
||||
return entries
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.sort[0] - b.sort[0] || a.sort[1] - b.sort[1] || a.sort[2].localeCompare(b.sort[2]),
|
||||
)
|
||||
.map((item) => item.entry);
|
||||
};
|
||||
|
||||
const label = (dir: string) =>
|
||||
categories.get(dir)?.title ?? labelFromDirname(dir.split('/').at(-1)!);
|
||||
|
||||
return { build, label };
|
||||
}
|
||||
|
||||
/** Sidebar described by a docusaurus sidebar file, used by the runner docs. */
|
||||
function fromDocusaurus(
|
||||
items: DocusaurusSidebarItem[],
|
||||
pages: SidebarPage[],
|
||||
tree: Tree,
|
||||
): SidebarEntry[] {
|
||||
const byDocId = new Map<string, SidebarPage>();
|
||||
for (const page of pages) {
|
||||
byDocId.set([page.meta.dir, page.meta.name].filter(Boolean).join('/'), page);
|
||||
}
|
||||
|
||||
const convert = (item: DocusaurusSidebarItem): SidebarEntry | undefined => {
|
||||
if (typeof item === 'string') {
|
||||
const page = byDocId.get(item);
|
||||
return page && link(page);
|
||||
}
|
||||
if (item.type === 'doc') {
|
||||
const page = byDocId.get(item.id);
|
||||
return page && link(page, item.label ?? page.title);
|
||||
}
|
||||
if (item.type === 'autogenerated') {
|
||||
// only reached inside a category, the caller flattens the result
|
||||
return {
|
||||
type: 'group',
|
||||
label: tree.label(item.dirName),
|
||||
entries: tree.build(item.dirName),
|
||||
collapsed: true,
|
||||
badge: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// a category whose only child is an autogenerated directory keeps the
|
||||
// translated label of the directory, like the docusaurus sidebars did
|
||||
const single =
|
||||
item.items.length === 1 && typeof item.items[0] === 'object' && 'dirName' in item.items[0]
|
||||
? item.items[0]
|
||||
: undefined;
|
||||
if (single) {
|
||||
return {
|
||||
type: 'group',
|
||||
label: tree.label(single.dirName),
|
||||
entries: tree.build(single.dirName),
|
||||
collapsed: item.collapsed ?? true,
|
||||
badge: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'group',
|
||||
label: item.label,
|
||||
entries: item.items.map(convert).filter((entry): entry is SidebarEntry => Boolean(entry)),
|
||||
collapsed: item.collapsed ?? true,
|
||||
badge: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
return items.map(convert).filter((entry): entry is SidebarEntry => Boolean(entry));
|
||||
}
|
||||
|
||||
/** Marks the entry of the current page and expands the groups leading to it. */
|
||||
export function markCurrent(entries: SidebarEntry[], pathname: string): SidebarEntry[] {
|
||||
return entries.map((entry) => {
|
||||
if (entry.type === 'link') {
|
||||
return { ...entry, isCurrent: entry.href === pathname };
|
||||
}
|
||||
const children = markCurrent(entry.entries, pathname);
|
||||
const contains = children.some(
|
||||
(child) => (child.type === 'link' && child.isCurrent) || (child.type === 'group' && !child.collapsed),
|
||||
);
|
||||
return { ...entry, entries: children, collapsed: entry.collapsed && !contains };
|
||||
});
|
||||
}
|
||||
|
||||
/** Flattens the sidebar into the reading order used for the previous and next links. */
|
||||
export function flattenSidebar(entries: SidebarEntry[]): SidebarLink[] {
|
||||
return entries.flatMap((entry) =>
|
||||
entry.type === 'link' ? [entry] : flattenSidebar(entry.entries),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user