mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 07:33:46 -06:00
606 lines
22 KiB
JavaScript
606 lines
22 KiB
JavaScript
// Builds the generated official channel catalog from publishable channel plugins.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { parse as parseYaml } from "yaml";
|
|
import officialExternalChannelSeed from "./lib/official-external-channel-seed.json" with { type: "json" };
|
|
import { collectExcludedPackagedExtensionDirs } from "./lib/packaged-extension-dirs.mjs";
|
|
import { isRecord, trimString } from "./lib/record-shared.mjs";
|
|
import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
|
|
|
|
/** Generated official channel catalog committed for source and packaged runtime consumers. */
|
|
export const OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH =
|
|
"scripts/lib/official-external-channel-catalog.json";
|
|
export const OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH = "docs/channels/index.md";
|
|
export const OFFICIAL_CHANNEL_DOCS_NAV_RELATIVE_PATH = "docs/docs.json";
|
|
|
|
/**
|
|
* Generated official channel catalog path in dist.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH = "dist/channel-catalog.json";
|
|
|
|
const OFFICIAL_CHANNEL_DOCS_START_MARKER = "<!-- BEGIN GENERATED: official channel catalog -->";
|
|
const OFFICIAL_CHANNEL_DOCS_END_MARKER = "<!-- END GENERATED: official channel catalog -->";
|
|
const WEBCHAT_DOCS_ENTRY = {
|
|
id: "webchat",
|
|
docsPath: "/web/webchat",
|
|
source: "built-in",
|
|
};
|
|
|
|
function readRepositoryPackageJsons(repoRoot) {
|
|
const extensionsRoot = path.join(repoRoot, "extensions");
|
|
if (!fs.existsSync(extensionsRoot)) {
|
|
return [];
|
|
}
|
|
|
|
const packageJsons = [];
|
|
const extensionDirectories = fs
|
|
.readdirSync(extensionsRoot, { withFileTypes: true })
|
|
.filter((dirent) => dirent.isDirectory())
|
|
.toSorted((left, right) => left.name.localeCompare(right.name));
|
|
for (const dirent of extensionDirectories) {
|
|
const packageJsonPath = path.join(extensionsRoot, dirent.name, "package.json");
|
|
if (!fs.existsSync(packageJsonPath)) {
|
|
continue;
|
|
}
|
|
try {
|
|
const pluginManifestPath = path.join(extensionsRoot, dirent.name, "openclaw.plugin.json");
|
|
packageJsons.push({
|
|
dirName: dirent.name,
|
|
packageJson: JSON.parse(fs.readFileSync(packageJsonPath, "utf8")),
|
|
pluginManifest: fs.existsSync(pluginManifestPath)
|
|
? JSON.parse(fs.readFileSync(pluginManifestPath, "utf8"))
|
|
: undefined,
|
|
});
|
|
} catch {
|
|
// Invalid package metadata must not prevent unrelated channels from being generated.
|
|
}
|
|
}
|
|
return packageJsons;
|
|
}
|
|
|
|
function readExcludedPackagedExtensionDirs(repoRoot) {
|
|
const packageJsonPath = path.join(repoRoot, "package.json");
|
|
if (!fs.existsSync(packageJsonPath)) {
|
|
return new Set();
|
|
}
|
|
const rootPackageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
return collectExcludedPackagedExtensionDirs(rootPackageJson);
|
|
}
|
|
|
|
function toCatalogInstall(value, packageName) {
|
|
const install = isRecord(value) ? value : {};
|
|
const clawhubSpec = trimString(install.clawhubSpec);
|
|
const npmSpec = trimString(install.npmSpec) || packageName;
|
|
if (!clawhubSpec && !npmSpec) {
|
|
return null;
|
|
}
|
|
const defaultChoice = trimString(install.defaultChoice);
|
|
const minHostVersion = trimString(install.minHostVersion);
|
|
const expectedIntegrity = trimString(install.expectedIntegrity);
|
|
return {
|
|
...(clawhubSpec ? { clawhubSpec } : {}),
|
|
...(npmSpec ? { npmSpec } : {}),
|
|
...(defaultChoice === "clawhub" || defaultChoice === "npm" || defaultChoice === "local"
|
|
? { defaultChoice }
|
|
: {}),
|
|
...(minHostVersion ? { minHostVersion } : {}),
|
|
...(expectedIntegrity ? { expectedIntegrity } : {}),
|
|
...(install.allowInvalidConfigRecovery === true ? { allowInvalidConfigRecovery: true } : {}),
|
|
};
|
|
}
|
|
|
|
function toCatalogManifestFields(value) {
|
|
const manifest = isRecord(value) ? value : {};
|
|
const catalog = isRecord(manifest.catalog) ? manifest.catalog : null;
|
|
const contracts = isRecord(manifest.contracts) ? manifest.contracts : null;
|
|
const channelConfigs = isRecord(manifest.channelConfigs) ? manifest.channelConfigs : null;
|
|
const providerEndpoints = Array.isArray(manifest.providerEndpoints)
|
|
? manifest.providerEndpoints
|
|
: null;
|
|
return {
|
|
...(catalog ? { catalog } : {}),
|
|
...(contracts ? { contracts } : {}),
|
|
...(channelConfigs ? { channelConfigs } : {}),
|
|
...(providerEndpoints ? { providerEndpoints } : {}),
|
|
};
|
|
}
|
|
|
|
function buildCatalogEntry(packageJson, pluginManifest) {
|
|
if (!isRecord(packageJson)) {
|
|
return null;
|
|
}
|
|
const packageName = trimString(packageJson.name);
|
|
const manifest = isRecord(packageJson.openclaw) ? packageJson.openclaw : null;
|
|
const release = manifest && isRecord(manifest.release) ? manifest.release : null;
|
|
const channel = manifest && isRecord(manifest.channel) ? manifest.channel : null;
|
|
if (!packageName || !channel || release?.publishToNpm !== true) {
|
|
return null;
|
|
}
|
|
const install = toCatalogInstall(manifest.install, packageName);
|
|
if (!install) {
|
|
return null;
|
|
}
|
|
const version = trimString(packageJson.version);
|
|
const description = trimString(packageJson.description);
|
|
return {
|
|
name: packageName,
|
|
...(version ? { version } : {}),
|
|
...(description ? { description } : {}),
|
|
source: "official",
|
|
kind: "channel",
|
|
openclaw: {
|
|
...toCatalogManifestFields(pluginManifest),
|
|
channel,
|
|
install,
|
|
},
|
|
};
|
|
}
|
|
|
|
function getCatalogChannelId(entry) {
|
|
return trimString(entry?.openclaw?.channel?.id) || trimString(entry?.name);
|
|
}
|
|
|
|
function getCatalogChannelKey(entry) {
|
|
return getCatalogChannelId(entry).toLowerCase();
|
|
}
|
|
|
|
function setUniqueCatalogEntry(entriesByChannelId, entry, owner) {
|
|
const channelId = getCatalogChannelId(entry);
|
|
const channelKey = getCatalogChannelKey(entry);
|
|
if (!channelKey) {
|
|
throw new Error(`official channel catalog entry from ${owner} is missing a channel id`);
|
|
}
|
|
const existing = entriesByChannelId.get(channelKey);
|
|
if (existing) {
|
|
throw new Error(
|
|
`duplicate official channel id "${channelId}" from ${existing.owner} and ${owner}`,
|
|
);
|
|
}
|
|
entriesByChannelId.set(channelKey, { entry, owner });
|
|
}
|
|
|
|
/**
|
|
* Collects publishable channel catalog entries from bundled and external channels.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export function buildOfficialChannelCatalog(params = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const seedEntriesByChannelId = new Map();
|
|
for (const entry of Array.isArray(officialExternalChannelSeed.entries)
|
|
? officialExternalChannelSeed.entries
|
|
: []) {
|
|
setUniqueCatalogEntry(
|
|
seedEntriesByChannelId,
|
|
entry,
|
|
`scripts/lib/official-external-channel-seed.json package "${trimString(entry?.name)}"`,
|
|
);
|
|
}
|
|
|
|
const repositoryEntriesByChannelId = new Map();
|
|
for (const { dirName, packageJson, pluginManifest } of readRepositoryPackageJsons(repoRoot)) {
|
|
const entry = buildCatalogEntry(packageJson, pluginManifest);
|
|
if (entry) {
|
|
setUniqueCatalogEntry(
|
|
repositoryEntriesByChannelId,
|
|
entry,
|
|
`extensions/${dirName}/package.json`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Repository packages deliberately replace same-id external seeds when a
|
|
// channel moves in tree. Duplicates within either ownership class are errors.
|
|
const entriesByChannelId = new Map(seedEntriesByChannelId);
|
|
for (const [channelId, entry] of repositoryEntriesByChannelId) {
|
|
entriesByChannelId.set(channelId, entry);
|
|
}
|
|
const entries = [...entriesByChannelId.values()].map(({ entry }) => entry);
|
|
entries.sort((left, right) => {
|
|
const leftId = trimString(left.openclaw?.channel?.id) || left.name;
|
|
const rightId = trimString(right.openclaw?.channel?.id) || right.name;
|
|
return leftId.localeCompare(rightId);
|
|
});
|
|
|
|
return { entries };
|
|
}
|
|
|
|
export function renderOfficialChannelCatalog(params = {}) {
|
|
return `${JSON.stringify(buildOfficialChannelCatalog(params), null, 2)}\n`;
|
|
}
|
|
|
|
export function writeOfficialChannelCatalog(params = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH);
|
|
return writeTextFileIfChanged(outputPath, renderOfficialChannelCatalog({ repoRoot }));
|
|
}
|
|
|
|
export function writeOfficialChannelCatalogSource(params = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH);
|
|
return writeTextFileIfChanged(outputPath, renderOfficialChannelCatalog({ repoRoot }));
|
|
}
|
|
|
|
export function checkOfficialChannelCatalogSource(params = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH);
|
|
const current = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, "utf8") : "";
|
|
return current === renderOfficialChannelCatalog({ repoRoot });
|
|
}
|
|
|
|
function toChannelDocsEntry(entry, sourceOverride) {
|
|
const channel = isRecord(entry?.openclaw?.channel) ? entry.openclaw.channel : null;
|
|
const exposure = channel && isRecord(channel.exposure) ? channel.exposure : null;
|
|
if (!channel || exposure?.docs === false) {
|
|
return null;
|
|
}
|
|
const id = trimString(channel.id);
|
|
if (!id) {
|
|
return null;
|
|
}
|
|
const docsPath = trimString(channel.docsPath) || `/channels/${id}`;
|
|
const source = sourceOverride ?? trimString(entry.source);
|
|
return {
|
|
id,
|
|
docsPath,
|
|
source:
|
|
source === "external" || source === "bundled" || source === "built-in" ? source : "official",
|
|
};
|
|
}
|
|
|
|
function readChannelDocsFrontmatter(repoRoot, entry) {
|
|
const route = entry.docsPath.split(/[?#]/u, 1)[0]?.replace(/^\/+/u, "");
|
|
if (!route) {
|
|
throw new Error(`channel ${entry.id} has an invalid docs route: ${entry.docsPath}`);
|
|
}
|
|
const candidates = [
|
|
path.join(repoRoot, "docs", `${route}.md`),
|
|
path.join(repoRoot, "docs", `${route}.mdx`),
|
|
];
|
|
const docsPath = candidates.find((candidate) => fs.existsSync(candidate));
|
|
if (!docsPath) {
|
|
throw new Error(`channel ${entry.id} docs route does not resolve: ${entry.docsPath}`);
|
|
}
|
|
const content = fs.readFileSync(docsPath, "utf8");
|
|
const frontmatterMatch = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(content);
|
|
if (!frontmatterMatch) {
|
|
throw new Error(`${path.relative(repoRoot, docsPath)} is missing YAML frontmatter`);
|
|
}
|
|
const frontmatter = parseYaml(frontmatterMatch[1]);
|
|
const label = trimString(frontmatter?.title);
|
|
const summary = trimString(frontmatter?.summary);
|
|
if (!label || !summary) {
|
|
throw new Error(`${path.relative(repoRoot, docsPath)} must define title and summary`);
|
|
}
|
|
return { ...entry, label, summary };
|
|
}
|
|
|
|
function isInstallableChannelManifest(manifest) {
|
|
const release = isRecord(manifest.release) ? manifest.release : null;
|
|
const install = isRecord(manifest.install) ? manifest.install : null;
|
|
return (
|
|
release?.publishToNpm === true ||
|
|
release?.publishToClawHub === true ||
|
|
Boolean(trimString(install?.npmSpec) || trimString(install?.clawhubSpec))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Builds the public docs projection from the install catalog plus bundled channel manifests.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export function buildOfficialChannelDocsCatalog(params = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const entriesByChannelId = new Map();
|
|
const excludedPackagedExtensionDirs = readExcludedPackagedExtensionDirs(repoRoot);
|
|
|
|
for (const entry of buildOfficialChannelCatalog({ repoRoot }).entries) {
|
|
const docsEntry = toChannelDocsEntry(entry);
|
|
if (docsEntry) {
|
|
entriesByChannelId.set(docsEntry.id, docsEntry);
|
|
}
|
|
}
|
|
|
|
for (const { dirName, packageJson } of readRepositoryPackageJsons(repoRoot)) {
|
|
const manifest = isRecord(packageJson?.openclaw) ? packageJson.openclaw : null;
|
|
const channel = manifest && isRecord(manifest.channel) ? manifest.channel : null;
|
|
if (!channel) {
|
|
continue;
|
|
}
|
|
const exposure = isRecord(channel.exposure) ? channel.exposure : null;
|
|
const isCoreBundled = !excludedPackagedExtensionDirs.has(dirName);
|
|
const isInstallable = isInstallableChannelManifest(manifest);
|
|
if (!isCoreBundled && !isInstallable) {
|
|
if (exposure?.docs === false) {
|
|
continue;
|
|
}
|
|
throw new Error(
|
|
`docs-visible channel ${trimString(channel.id) || dirName} is neither bundled nor installable`,
|
|
);
|
|
}
|
|
const docsEntry = toChannelDocsEntry(
|
|
{ openclaw: { channel } },
|
|
isCoreBundled ? "bundled" : "official",
|
|
);
|
|
if (docsEntry) {
|
|
entriesByChannelId.set(docsEntry.id, docsEntry);
|
|
}
|
|
}
|
|
|
|
entriesByChannelId.set(WEBCHAT_DOCS_ENTRY.id, WEBCHAT_DOCS_ENTRY);
|
|
const entries = [...entriesByChannelId.values()].map((entry) =>
|
|
readChannelDocsFrontmatter(repoRoot, entry),
|
|
);
|
|
entries.sort(
|
|
(left, right) =>
|
|
left.label.localeCompare(right.label, "en") || left.id.localeCompare(right.id, "en"),
|
|
);
|
|
return { entries };
|
|
}
|
|
|
|
function renderChannelDocsSummary(entry) {
|
|
const summary = entry.summary.replace(/[.!?]+$/u, "");
|
|
const normalizedSummary = summary
|
|
? `${summary.slice(0, 1).toUpperCase()}${summary.slice(1)}`
|
|
: `${entry.label} messaging for OpenClaw`;
|
|
const sourceLabel =
|
|
entry.source === "external"
|
|
? "external plugin"
|
|
: entry.source === "bundled"
|
|
? "bundled plugin"
|
|
: entry.source === "built-in"
|
|
? "included in core"
|
|
: "official plugin";
|
|
return `- [${entry.label}](${entry.docsPath}) - ${normalizedSummary} (${sourceLabel}).`;
|
|
}
|
|
|
|
function renderOfficialChannelDocsBlock(params = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const lines = buildOfficialChannelDocsCatalog({ repoRoot }).entries.map(renderChannelDocsSummary);
|
|
return [
|
|
OFFICIAL_CHANNEL_DOCS_START_MARKER,
|
|
"<!-- Generated by `pnpm channels:catalog:gen`. Edit manifests/seed for membership and routes; edit page frontmatter for public names and summaries. -->",
|
|
"",
|
|
...lines,
|
|
"",
|
|
OFFICIAL_CHANNEL_DOCS_END_MARKER,
|
|
].join("\n");
|
|
}
|
|
|
|
function replaceOfficialChannelDocsBlock(current, block) {
|
|
const startIndex = current.indexOf(OFFICIAL_CHANNEL_DOCS_START_MARKER);
|
|
const endIndex = current.indexOf(OFFICIAL_CHANNEL_DOCS_END_MARKER);
|
|
const startCount = current.split(OFFICIAL_CHANNEL_DOCS_START_MARKER).length - 1;
|
|
const endCount = current.split(OFFICIAL_CHANNEL_DOCS_END_MARKER).length - 1;
|
|
if (
|
|
startCount !== 1 ||
|
|
endCount !== 1 ||
|
|
startIndex === -1 ||
|
|
endIndex === -1 ||
|
|
endIndex < startIndex
|
|
) {
|
|
throw new Error(
|
|
`${OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH} must contain exactly one generated channel marker pair`,
|
|
);
|
|
}
|
|
const afterEndIndex = endIndex + OFFICIAL_CHANNEL_DOCS_END_MARKER.length;
|
|
return `${current.slice(0, startIndex)}${block}${current.slice(afterEndIndex)}`;
|
|
}
|
|
|
|
export function renderOfficialChannelDocsIndex(params = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH);
|
|
const current = fs.readFileSync(outputPath, "utf8");
|
|
return replaceOfficialChannelDocsBlock(current, renderOfficialChannelDocsBlock({ repoRoot }));
|
|
}
|
|
|
|
export function writeOfficialChannelDocsIndex(params = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH);
|
|
return writeTextFileIfChanged(outputPath, renderOfficialChannelDocsIndex({ repoRoot }));
|
|
}
|
|
|
|
export function checkOfficialChannelDocsIndex(params = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH);
|
|
if (!fs.existsSync(outputPath)) {
|
|
return false;
|
|
}
|
|
const current = fs.readFileSync(outputPath, "utf8");
|
|
try {
|
|
return current === renderOfficialChannelDocsIndex({ repoRoot });
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function collectDocsNavPageCounts(node, counts = new Map()) {
|
|
if (Array.isArray(node)) {
|
|
for (const item of node) {
|
|
collectDocsNavPageCounts(item, counts);
|
|
}
|
|
return counts;
|
|
}
|
|
if (!isRecord(node)) {
|
|
return counts;
|
|
}
|
|
if (Array.isArray(node.pages)) {
|
|
for (const page of node.pages) {
|
|
if (typeof page === "string") {
|
|
const route = page.replace(/^\/+/u, "");
|
|
counts.set(route, (counts.get(route) ?? 0) + 1);
|
|
} else {
|
|
collectDocsNavPageCounts(page, counts);
|
|
}
|
|
}
|
|
}
|
|
for (const value of Object.values(node)) {
|
|
if (value !== node.pages) {
|
|
collectDocsNavPageCounts(value, counts);
|
|
}
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
function findEnglishChannelsTab(docsConfig) {
|
|
const navigation = isRecord(docsConfig.navigation) ? docsConfig.navigation : null;
|
|
const languages = Array.isArray(navigation?.languages) ? navigation.languages : [];
|
|
const english = languages.find(
|
|
(language) => isRecord(language) && trimString(language.language) === "en",
|
|
);
|
|
const tabs = isRecord(english) && Array.isArray(english.tabs) ? english.tabs : [];
|
|
return tabs.find((tab) => isRecord(tab) && trimString(tab.tab) === "Channels") ?? null;
|
|
}
|
|
|
|
function readDocsNavCounts(repoRoot) {
|
|
const navPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_NAV_RELATIVE_PATH);
|
|
const docsConfig = JSON.parse(fs.readFileSync(navPath, "utf8"));
|
|
return {
|
|
all: collectDocsNavPageCounts(docsConfig),
|
|
channels: collectDocsNavPageCounts(findEnglishChannelsTab(docsConfig)),
|
|
};
|
|
}
|
|
|
|
function buildHiddenChannelDocsRoutes(repoRoot) {
|
|
const channelsById = new Map();
|
|
for (const entry of Array.isArray(officialExternalChannelSeed.entries)
|
|
? officialExternalChannelSeed.entries
|
|
: []) {
|
|
const channel = isRecord(entry?.openclaw?.channel) ? entry.openclaw.channel : null;
|
|
const channelId = trimString(channel?.id);
|
|
if (channelId && channel) {
|
|
channelsById.set(channelId, channel);
|
|
}
|
|
}
|
|
for (const { packageJson } of readRepositoryPackageJsons(repoRoot)) {
|
|
const manifest = isRecord(packageJson?.openclaw) ? packageJson.openclaw : null;
|
|
const channel = manifest && isRecord(manifest.channel) ? manifest.channel : null;
|
|
const channelId = trimString(channel?.id);
|
|
if (channelId && channel) {
|
|
channelsById.set(channelId, channel);
|
|
}
|
|
}
|
|
|
|
const routes = new Set();
|
|
for (const channel of channelsById.values()) {
|
|
const exposure = isRecord(channel.exposure) ? channel.exposure : null;
|
|
const docsPath = trimString(channel.docsPath);
|
|
if (exposure?.docs !== false || !docsPath.startsWith("/")) {
|
|
continue;
|
|
}
|
|
const route = docsPath.split("#", 1)[0]?.replace(/^\/+/u, "");
|
|
if (route) {
|
|
routes.add(route);
|
|
}
|
|
}
|
|
return routes;
|
|
}
|
|
|
|
export function findMissingOfficialChannelDocsNavRoutes(params = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const navCounts = readDocsNavCounts(repoRoot);
|
|
const missing = new Set();
|
|
for (const entry of buildOfficialChannelDocsCatalog({ repoRoot }).entries) {
|
|
if (!entry.docsPath.startsWith("/")) {
|
|
continue;
|
|
}
|
|
const route = entry.docsPath.split("#", 1)[0]?.replace(/^\/+/u, "");
|
|
const counts = route?.startsWith("channels/") ? navCounts.channels : navCounts.all;
|
|
if (route && (counts.get(route) ?? 0) === 0) {
|
|
missing.add(route);
|
|
}
|
|
}
|
|
return [...missing].toSorted((left, right) => left.localeCompare(right, "en"));
|
|
}
|
|
|
|
export function findUnexpectedOfficialChannelDocsNavRoutes(params = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const navCounts = readDocsNavCounts(repoRoot).all;
|
|
return [...buildHiddenChannelDocsRoutes(repoRoot)]
|
|
.filter((route) => (navCounts.get(route) ?? 0) > 0)
|
|
.toSorted((left, right) => left.localeCompare(right, "en"));
|
|
}
|
|
|
|
export function findDuplicateOfficialChannelDocsNavRoutes(params = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const navCounts = readDocsNavCounts(repoRoot).all;
|
|
const duplicateRoutes = new Set();
|
|
for (const entry of buildOfficialChannelDocsCatalog({ repoRoot }).entries) {
|
|
const route = entry.docsPath.split("#", 1)[0]?.replace(/^\/+/u, "");
|
|
if (route && (navCounts.get(route) ?? 0) > 1) {
|
|
duplicateRoutes.add(route);
|
|
}
|
|
}
|
|
return [...duplicateRoutes].toSorted((left, right) => left.localeCompare(right, "en"));
|
|
}
|
|
|
|
function reportOfficialChannelDocsNavIssues(params = {}) {
|
|
const issueGroups = [
|
|
{
|
|
label: "is missing channel routes",
|
|
routes: findMissingOfficialChannelDocsNavRoutes(params),
|
|
},
|
|
{
|
|
label: "exposes hidden channel routes",
|
|
routes: findUnexpectedOfficialChannelDocsNavRoutes(params),
|
|
},
|
|
{
|
|
label: "duplicates channel routes",
|
|
routes: findDuplicateOfficialChannelDocsNavRoutes(params),
|
|
},
|
|
];
|
|
let failed = false;
|
|
for (const issue of issueGroups) {
|
|
if (issue.routes.length === 0) {
|
|
continue;
|
|
}
|
|
console.error(
|
|
`${OFFICIAL_CHANNEL_DOCS_NAV_RELATIVE_PATH} ${issue.label}: ${issue.routes.join(", ")}`,
|
|
);
|
|
failed = true;
|
|
}
|
|
return failed;
|
|
}
|
|
|
|
function main(argv = process.argv.slice(2)) {
|
|
const write = argv.includes("--write");
|
|
const check = argv.includes("--check");
|
|
if (write === check) {
|
|
console.error("usage: node scripts/write-official-channel-catalog.mjs --write|--check");
|
|
process.exitCode = 2;
|
|
return;
|
|
}
|
|
if (write) {
|
|
writeOfficialChannelCatalogSource();
|
|
writeOfficialChannelDocsIndex();
|
|
if (reportOfficialChannelDocsNavIssues()) {
|
|
process.exitCode = 1;
|
|
}
|
|
return;
|
|
}
|
|
let failed = false;
|
|
if (!checkOfficialChannelCatalogSource()) {
|
|
console.error(
|
|
`${OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH} is stale. Run \`pnpm channels:catalog:gen\`.`,
|
|
);
|
|
failed = true;
|
|
}
|
|
if (!checkOfficialChannelDocsIndex()) {
|
|
console.error(
|
|
`${OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH} is stale. Run \`pnpm channels:catalog:gen\`.`,
|
|
);
|
|
failed = true;
|
|
}
|
|
if (reportOfficialChannelDocsNavIssues()) {
|
|
failed = true;
|
|
}
|
|
if (failed) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
main();
|
|
}
|