openclaw/scripts/lib/plugin-inventory-doc.mts
Vincent Koc 633b84f222
docs(plugins): mark generated reference pages and fix their shared template (#140254)
* docs(plugins): mark generated reference pages and fix their shared template

Two generator files change; the other 153 files are their regenerated
output from `pnpm plugins:inventory:gen`.

- Emit a "generated, do not edit" banner naming the regeneration command
  and the manual-block markers. None of the 151 reference pages said they
  were generated, so a contributor edit was silently overwritten.
- Give generated reference titles a `reference` suffix. Eight of them
  collided with a hand-written guide title (beam, geolocation,
  google-meet, logbook, teams-meetings, webhooks, workboard,
  zoom-meetings). Duplicate frontmatter titles across docs/ now total 0.
- Render the surface list as a list instead of a semicolon-joined
  sentence, and use plain conjunctions for install routes. Strict STE
  hard violations across docs/plugins/reference/ drop from 178 to 20.
- Drop the body H1, which duplicated the frontmatter title Mintlify
  already renders.
- Fix a generator bug found while testing: the marker-less fallback in
  `extractManualReferenceSections` matched only the first line under
  `## Surface`, so a second `--write` run captured later bullets into a
  fabricated manual block. `--write` is now idempotent and `--check`
  passes across repeated runs.

All 12 hand-written manual blocks are preserved.

* test(scripts): follow resolvePluginSurface to its list contract

resolvePluginSurface now returns one string per surface item instead of
a semicolon-joined sentence, so the generator can render a list. The
four assertions move from toBe(<joined string>) to toEqual(<array>) and
pick up the capitalised labels.

The "generic fallback" case changes meaning rather than disappearing.
The empty manifest now yields [], and renderSurface() prints "This
plugin declares no channels, providers, commands, or contracts." for an
empty list, which tells a reader more than the old "plugin". The test
is renamed to say what it now checks, with a comment pointing at the
new home of the fallback.

No generated page has an empty Surface section, so no page changes
because of this.
2026-09-07 03:14:31 +08:00

128 lines
4.7 KiB
TypeScript

type PluginSurfaceManifest = {
id?: string;
channels?: string[];
providers?: string[];
cliCommands?: Array<{ name?: string }>;
commandAliases?: Array<{ name?: string; kind?: string }>;
contracts?: Record<string, unknown>;
dashboard?: Partial<Record<"actionVerbs" | "dataBindings", Array<{ id?: string }>>>;
skills?: unknown[];
};
type PluginInventoryCoverageEntry = {
dirName: string;
id: string;
};
function duplicateValues(values: string[]) {
return values
.filter((value, index) => values.indexOf(value) !== index)
.filter((value, index, duplicates) => duplicates.indexOf(value) === index)
.toSorted((left, right) => left.localeCompare(right));
}
export function assertPluginInventoryCoverage(
collectedEntries: PluginInventoryCoverageEntry[],
manifestEntries: PluginInventoryCoverageEntry[],
) {
const problems: string[] = [];
for (const key of ["dirName", "id"] as const) {
const collected = collectedEntries.map((entry) => entry[key]);
const manifests = manifestEntries.map((entry) => entry[key]);
const missing = manifests
.filter((value) => !collected.includes(value))
.toSorted((left, right) => left.localeCompare(right));
const extra = collected
.filter((value) => !manifests.includes(value))
.toSorted((left, right) => left.localeCompare(right));
const duplicateIds = key === "id" ? duplicateValues(manifests) : [];
if (missing.length > 0) {
problems.push(`missing ${key}s: ${missing.join(", ")}`);
}
if (extra.length > 0) {
problems.push(`extra ${key}s: ${extra.join(", ")}`);
}
if (duplicateIds.length > 0) {
problems.push(`duplicate manifest ids: ${duplicateIds.join(", ")}`);
}
}
if (problems.length > 0) {
throw new Error(`plugin inventory coverage mismatch; ${problems.join("; ")}`);
}
}
function formatIdentifiers(values: string[]) {
return values.map((value) => `\`${value}\``).join(", ");
}
function encodeDashboardPluginIdSegment(pluginId: string) {
return pluginId.replaceAll("%", "%25").replaceAll(".", "%2E");
}
function resolveDashboardCapabilityIds(
manifest: PluginSurfaceManifest,
field: "dataBindings" | "actionVerbs",
) {
if (typeof manifest.id !== "string" || !Array.isArray(manifest.dashboard?.[field])) {
return [];
}
const pluginIdSegment = encodeDashboardPluginIdSegment(manifest.id);
return manifest.dashboard[field]
.map((entry) =>
typeof entry?.id === "string" && entry.id.length > 0
? `${pluginIdSegment}.${entry.id}`
: null,
)
.filter((value) => value !== null);
}
// Returns one surface item per line so the reference template can render a list.
// STE bans the semicolon, so these items must never be joined into one sentence.
export function resolvePluginSurface(manifest: PluginSurfaceManifest): string[] {
const parts: string[] = [];
if (Array.isArray(manifest.channels) && manifest.channels.length > 0) {
parts.push(`Channels: ${formatIdentifiers(manifest.channels)}`);
}
if (Array.isArray(manifest.providers) && manifest.providers.length > 0) {
parts.push(`Providers: ${formatIdentifiers(manifest.providers)}`);
}
const cliCommands = [
...new Set(
(manifest.cliCommands ?? [])
.map((command) => command.name?.trim())
.filter((name): name is string => Boolean(name)),
),
].toSorted((left, right) => left.localeCompare(right));
if (cliCommands.length > 0) {
parts.push(`CLI commands: ${formatIdentifiers(cliCommands.map((name) => `openclaw ${name}`))}`);
}
const slashCommands = [
...new Set(
(manifest.commandAliases ?? [])
.filter((alias) => alias.kind === "runtime-slash")
.map((alias) => alias.name?.trim())
.filter((name): name is string => Boolean(name)),
),
].toSorted((left, right) => left.localeCompare(right));
if (slashCommands.length > 0) {
parts.push(`Slash commands: ${formatIdentifiers(slashCommands.map((name) => `/${name}`))}`);
}
const contracts = Object.keys(manifest.contracts ?? {}).toSorted((left, right) =>
left.localeCompare(right),
);
if (contracts.length > 0) {
parts.push(`Contracts: ${formatIdentifiers(contracts)}`);
}
const dashboardDataBindings = resolveDashboardCapabilityIds(manifest, "dataBindings");
if (dashboardDataBindings.length > 0) {
parts.push(`Dashboard data bindings: ${formatIdentifiers(dashboardDataBindings)}`);
}
const dashboardActionVerbs = resolveDashboardCapabilityIds(manifest, "actionVerbs");
if (dashboardActionVerbs.length > 0) {
parts.push(`Dashboard action verbs: ${formatIdentifiers(dashboardActionVerbs)}`);
}
if (Array.isArray(manifest.skills) && manifest.skills.length > 0) {
parts.push("Skills");
}
return parts;
}