Files
better-auth/docs/scripts/validate-sidebar-icons.ts
T

179 lines
5.7 KiB
TypeScript

import { createHash } from "node:crypto";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import * as z from "zod";
import { authenticationIcons } from "../components/icons/authentication.tsx";
import { brandIcons } from "../components/icons/brands.tsx";
import type { IconKey } from "../components/icons/index.tsx";
import { Icons } from "../components/icons/index.tsx";
import { pageIcons as pageIconRegistry } from "../components/icons/pages.tsx";
import { pluginIcons } from "../components/icons/plugins.tsx";
import { sectionIcons as sectionIconRegistry } from "../components/icons/sections.tsx";
import { docsVersionSources } from "../lib/docs-version-sources.ts";
import { docsVersions } from "../lib/docs-versions.ts";
const sectionMetaSchema = z.object({
title: z.string(),
icon: z.string().optional(),
});
const iconMetaSchema = z.object({ icon: z.string().optional() });
const contentDirectory = "content/docs";
const errors: string[] = [];
const iconGroups = [
brandIcons,
sectionIconRegistry,
authenticationIcons,
pluginIcons,
pageIconRegistry,
];
const iconKeyCounts = new Map<string, number>();
for (const iconGroup of iconGroups) {
for (const key of Object.keys(iconGroup)) {
iconKeyCounts.set(key, (iconKeyCounts.get(key) ?? 0) + 1);
}
}
const duplicateIconKeys = [...iconKeyCounts]
.filter(([, count]) => count > 1)
.map(([key]) => key);
if (duplicateIconKeys.length > 0) {
errors.push(`duplicate icon keys: ${duplicateIconKeys.join(", ")}`);
}
function hasIcon(key: string): key is IconKey {
return Object.hasOwn(Icons, key);
}
function renderIcon(key: string, source: string) {
if (!hasIcon(key)) {
errors.push(`${source}: unknown icon ${key}`);
return "";
}
return renderToStaticMarkup(createElement(Icons[key]));
}
function getFiles(directory: string, fileName: string): string[] {
const files: string[] = [];
for (const item of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, item.name);
if (item.isDirectory()) files.push(...getFiles(path, fileName));
else if (item.name === fileName || item.name.endsWith(fileName)) {
files.push(path);
}
}
return files;
}
const sectionMetaFiles = [
"content/docs/meta.json",
"content/docs/concepts/meta.json",
"content/docs/authentication/meta.json",
"content/docs/adapters/meta.json",
"content/docs/integrations/meta.json",
"content/docs/infrastructure/meta.json",
"content/docs/plugins/meta.json",
"content/docs/guides/meta.json",
"content/docs/ai-resources/meta.json",
"content/docs/reference/meta.json",
];
const sectionIcons = sectionMetaFiles
.map((file) => {
const meta = sectionMetaSchema.parse(
JSON.parse(readFileSync(file, "utf8")),
);
if (!meta.icon) errors.push(`${file}: missing icon`);
return [meta.title, renderIcon(meta.icon ?? "", file)];
})
.toSorted(([left], [right]) => left.localeCompare(right));
const pageIcons = getFiles(contentDirectory, ".mdx")
.flatMap((file) => {
const content = readFileSync(file, "utf8");
const key = content.match(/^icon:\s*(\S+)$/m)?.[1];
if (!key) return [];
const segments = relative(contentDirectory, file)
.replace(/\.mdx$/, "")
.split(/[\\/]/);
if (segments.at(-1) === "index") segments.pop();
const href = `/docs/${segments.join("/")}`.replace(/\/$/, "");
return [[href, renderIcon(key, file)]];
})
.toSorted(([left], [right]) => left.localeCompare(right));
const aiMeta = readFileSync("content/docs/ai-resources/meta.json", "utf8");
const llmsIcon = aiMeta.match(
/"\[([^\]"]+)\]\[LLMs\.txt\]\(\/llms\.txt\)"/,
)?.[1];
if (!llmsIcon)
errors.push("content/docs/ai-resources/meta.json: missing LLMs.txt icon");
pageIcons.push([
"/llms.txt",
renderIcon(llmsIcon ?? "", "content/docs/ai-resources/meta.json"),
]);
pageIcons.sort(([left], [right]) => left.localeCompare(right));
for (const file of getFiles(contentDirectory, "meta.json")) {
const meta = iconMetaSchema.parse(JSON.parse(readFileSync(file, "utf8")));
if (meta.icon) renderIcon(meta.icon, file);
}
for (const version of docsVersions) {
if (version.id === "latest") continue;
const versionContentDirectory = join(
"content",
docsVersionSources[version.id].contentDirectory,
);
if (!existsSync(versionContentDirectory)) {
errors.push(
`${versionContentDirectory}: no MDX files found. Run sync-versions first.`,
);
continue;
}
const mdxFiles = getFiles(versionContentDirectory, ".mdx");
if (mdxFiles.length === 0) {
errors.push(
`${versionContentDirectory}: no MDX files found. Run sync-versions first.`,
);
continue;
}
for (const file of mdxFiles) {
const content = readFileSync(file, "utf8");
const icon = content.match(/^icon:\s*(\S+)$/m)?.[1];
if (icon) renderIcon(icon, file);
}
for (const file of getFiles(versionContentDirectory, "meta.json")) {
const meta = iconMetaSchema.parse(JSON.parse(readFileSync(file, "utf8")));
if (meta.icon) renderIcon(meta.icon, file);
}
}
const hash = createHash("sha256")
.update(JSON.stringify({ sections: sectionIcons, pages: pageIcons }))
.digest("hex");
const expectedHash =
"873e195b76a3984e38731f0ee2f8c11fcebc4f0c41cdd44187cc1fa73c4c6129";
if (sectionIcons.length !== 10) {
errors.push(`expected 10 section icons, received ${sectionIcons.length}`);
}
if (pageIcons.length !== 156) {
errors.push(`expected 156 page icons, received ${pageIcons.length}`);
}
if (hash !== expectedHash) {
errors.push(
`registry hash differs from origin/main: expected ${expectedHash}, received ${hash}`,
);
}
if (errors.length > 0) {
throw new Error(`Sidebar icon validation failed:\n${errors.join("\n")}`);
}
console.log(
`[validate-sidebar-icons] ${sectionIcons.length} sections and ${pageIcons.length} page icons matched origin/main`,
);