253 lines
7.1 KiB
TypeScript
253 lines
7.1 KiB
TypeScript
import {
|
|
createMdxContentService,
|
|
type MdxCompileResult as CoreMdxCompileResult,
|
|
} from "@docubook/core";
|
|
import { cache } from "react";
|
|
import path from "path";
|
|
import { execFile } from "child_process";
|
|
import { promisify } from "util";
|
|
import { page_routes, ROUTES } from "./routes";
|
|
import type { TocItem } from "./toc";
|
|
import { mdxComponents as components } from "./mdx-components";
|
|
import { toIsoDateOnly } from "./utils";
|
|
|
|
export type MdxCompileResult<Frontmatter> = CoreMdxCompileResult<Frontmatter>;
|
|
|
|
export type DocsForSlugResult = MdxCompileResult<BaseMdxFrontmatter> & {
|
|
filePath: string;
|
|
tocs: TocItem[];
|
|
};
|
|
|
|
// Shared frontmatter shape used by docs pages.
|
|
// Keep this close to exported result types for easier discovery.
|
|
export type BaseMdxFrontmatter = {
|
|
title: string;
|
|
description: string;
|
|
image: string;
|
|
date?: string | Date;
|
|
};
|
|
|
|
// `React.cache` deduplicates calls within a single server-render pass.
|
|
// Keep request-level cache in app layer, while markdown pipeline lives in core.
|
|
|
|
const FILE_GIT_DATE_CACHE_MAX = 1000;
|
|
const fileGitDateCache = new Map<string, Date | undefined>();
|
|
|
|
function cacheSet(key: string, value: Date | undefined) {
|
|
if (fileGitDateCache.size >= FILE_GIT_DATE_CACHE_MAX) {
|
|
const firstKey = fileGitDateCache.keys().next().value!;
|
|
fileGitDateCache.delete(firstKey);
|
|
}
|
|
fileGitDateCache.set(key, value);
|
|
}
|
|
let repoLastCommitDateCache: Date | undefined | null = null;
|
|
let gitRootCache: string | undefined;
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
async function getGitRootDir(): Promise<string | undefined> {
|
|
if (gitRootCache !== undefined) return gitRootCache;
|
|
|
|
try {
|
|
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"]);
|
|
gitRootCache = stdout.trim() || "";
|
|
return gitRootCache || undefined;
|
|
} catch {
|
|
gitRootCache = "";
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async function getFileLastCommitDate(absoluteFilePath: string): Promise<Date | undefined> {
|
|
if (fileGitDateCache.has(absoluteFilePath)) {
|
|
return fileGitDateCache.get(absoluteFilePath);
|
|
}
|
|
|
|
try {
|
|
const gitRoot = await getGitRootDir();
|
|
if (!gitRoot) {
|
|
cacheSet(absoluteFilePath, undefined);
|
|
return undefined;
|
|
}
|
|
|
|
const relativePath = path.relative(gitRoot, absoluteFilePath);
|
|
if (
|
|
relativePath.startsWith("..") ||
|
|
!/^[a-zA-Z0-9\-_/.\s]+$/.test(relativePath) ||
|
|
/(^|\/)\.\.($|\/)/.test(relativePath)
|
|
) {
|
|
cacheSet(absoluteFilePath, undefined);
|
|
return undefined;
|
|
}
|
|
|
|
const { stdout } = await execFileAsync(
|
|
"git",
|
|
["log", "-1", "--format=%cI", "--", relativePath],
|
|
{ cwd: gitRoot }
|
|
);
|
|
|
|
const rawDate = stdout.trim();
|
|
if (!rawDate) {
|
|
cacheSet(absoluteFilePath, undefined);
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = new Date(rawDate);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
cacheSet(absoluteFilePath, undefined);
|
|
return undefined;
|
|
}
|
|
|
|
cacheSet(absoluteFilePath, parsed);
|
|
return parsed;
|
|
} catch {
|
|
cacheSet(absoluteFilePath, undefined);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async function getRepoLastCommitDate(): Promise<Date | undefined> {
|
|
if (repoLastCommitDateCache !== null) {
|
|
return repoLastCommitDateCache ?? undefined;
|
|
}
|
|
|
|
try {
|
|
const gitRoot = await getGitRootDir();
|
|
if (!gitRoot) {
|
|
repoLastCommitDateCache = undefined;
|
|
return undefined;
|
|
}
|
|
|
|
const { stdout } = await execFileAsync("git", ["log", "-1", "--format=%cI"], {
|
|
cwd: gitRoot,
|
|
});
|
|
|
|
const rawDate = stdout.trim();
|
|
if (!rawDate) {
|
|
repoLastCommitDateCache = undefined;
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = new Date(rawDate);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
repoLastCommitDateCache = undefined;
|
|
return undefined;
|
|
}
|
|
|
|
repoLastCommitDateCache = parsed;
|
|
return parsed;
|
|
} catch {
|
|
repoLastCommitDateCache = undefined;
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
const docsService = createMdxContentService<BaseMdxFrontmatter, TocItem>({
|
|
parseOptions: { components },
|
|
cacheFn: cache,
|
|
frontmatterEnricher: async (frontmatter, absoluteFilePath) => {
|
|
if (!frontmatter.date) {
|
|
const gitDate = await getFileLastCommitDate(absoluteFilePath);
|
|
if (gitDate) return { ...frontmatter, date: toIsoDateOnly(gitDate) };
|
|
|
|
const repoDate = await getRepoLastCommitDate();
|
|
if (repoDate) return { ...frontmatter, date: toIsoDateOnly(repoDate) };
|
|
|
|
// Keep `date` non-empty even outside git context.
|
|
return { ...frontmatter, date: "12-12-2025" };
|
|
}
|
|
return frontmatter;
|
|
},
|
|
});
|
|
|
|
// Return frontmatter only for a docs slug.
|
|
export async function getDocsFrontmatterForSlug(
|
|
slug: string
|
|
): Promise<BaseMdxFrontmatter | undefined> {
|
|
try {
|
|
return await docsService.getFrontmatterForSlug(slug);
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get full compiled documentation (public API)
|
|
* Returns: JSX content, frontmatter, TOCs, file path
|
|
*/
|
|
export async function getDocsForSlug(slug: string): Promise<DocsForSlugResult | undefined> {
|
|
try {
|
|
return await docsService.getCompiledForSlug(slug);
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get only table of contents for a slug
|
|
*/
|
|
export async function getDocsTocs(slug: string) {
|
|
try {
|
|
return await docsService.getTocsForSlug(slug);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Build static params for the docs route segment.
|
|
export function getDocsStaticParams() {
|
|
// Ensure the root docs page (docs/index.mdx) is statically generated at /docs.
|
|
// This page is not part of the generated docs tree routes by default.
|
|
// Filter out routes marked as noLink (parent categories that don't have their own page)
|
|
return [
|
|
{ slug: [] },
|
|
...page_routes
|
|
.map((page) => ({
|
|
slug: page.href.split("/").filter(Boolean),
|
|
}))
|
|
.filter(({ slug }) => slug.length > 0), // Exclude empty slugs
|
|
];
|
|
}
|
|
|
|
// Get previous and next page entries from the route list.
|
|
export function getPreviousNext(path: string) {
|
|
const index = page_routes.findIndex(({ href }) => href == `/${path}`);
|
|
return {
|
|
prev: page_routes[index - 1],
|
|
next: page_routes[index + 1],
|
|
};
|
|
}
|
|
|
|
// Collect frontmatter of direct child pages under the given docs path.
|
|
export async function getAllChilds(pathString: string) {
|
|
const items = pathString.split("/").filter((it) => it !== "");
|
|
let nestedRoutes = ROUTES;
|
|
|
|
// Resolve the nested route branch for the requested path.
|
|
let resolvedHref = "";
|
|
for (const it of items) {
|
|
const found = nestedRoutes.find((innerIt) => innerIt.href === `/${it}`);
|
|
if (!found) break;
|
|
resolvedHref += found.href;
|
|
nestedRoutes = found.items ?? [];
|
|
}
|
|
if (!resolvedHref) return [];
|
|
|
|
const children = await Promise.all(
|
|
nestedRoutes.map(async (it) => {
|
|
const slug = `${resolvedHref}${it.href}`.replace(/^\/+/, "");
|
|
const frontmatter = await docsService.getFrontmatterForSlug(slug).catch(() => undefined);
|
|
|
|
if (!frontmatter) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...frontmatter,
|
|
href: `/docs${resolvedHref}${it.href}`,
|
|
};
|
|
})
|
|
);
|
|
|
|
return children.filter((child): child is BaseMdxFrontmatter & { href: string } => child !== null);
|
|
}
|