mirror of
https://gitea.com/gitea/docs.git
synced 2026-11-04 09:15:33 +00:00
Follow-up to #588. - rename `DocusaurusSidebarItem` -> `SidebarSourceItem`, `fromDocusaurus` -> `fromSidebarSource`, `docusaurusSidebars` -> `sidebarSources` - reword comments and READMEs that described behaviour by comparison with docusaurus; fix the stale note in `sites/docs/src/config/api.ts` - drop `.docusaurus/` from `.gitignore` No functional change. `pnpm check` has 0 errors and `pnpm build` still builds 6690 pages. <!-- cloudflare-preview --> Preview: https://pr-589.docs-gitea-com.pages.dev Reviewed-on: https://gitea.com/gitea/docs/pulls/589 Reviewed-by: silverwind <[email protected]>
427 lines
15 KiB
TypeScript
427 lines
15 KiB
TypeScript
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 their original layout, 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 `slug` moves the page
|
|
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,
|
|
// the version and the route are part of the stored data, so they have to
|
|
// invalidate the entry too: an identical page of another version, or of the
|
|
// same version moved to another prefix, must not reuse a stale entry
|
|
digest: generateDigest(
|
|
`${transformVersion}\u0000${segment.product.id}/${segment.version.id}/${segment.locale}/${segment.prefix}\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 generated for a category. */
|
|
interface ParsedCategory {
|
|
segment: Segment;
|
|
id: string;
|
|
dir: string;
|
|
title: string;
|
|
description?: string;
|
|
order?: number;
|
|
body: string;
|
|
}
|
|
|
|
/**
|
|
* Recreates the index pages 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.segment.product.id}/${category.segment.version.id}/${category.segment.locale}/${category.segment.prefix}\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;
|
|
}
|