Files
docmd-io--docmd/scripts/build-plugin-registry.mjs
wehub-resource-sync 6db8fca185
docmd CI verification / verify (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:31:55 +08:00

169 lines
6.0 KiB
JavaScript

#!/usr/bin/env node
/**
* --------------------------------------------------------------------
* build-plugin-registry : single source of truth for the official plugin
* and template catalog.
*
* Scans packages/{plugins,templates,engines}/*, reads each package's
* `package.json#docmd` namespace, and emits a generated JSON registry
* that the runtime loader (`packages/api/src/hooks.ts`) consumes.
*
* Why generated:
* - Eliminates drift between packages/ and the hand-maintained
* plugins/installer/registry/plugins.json (which used to be a
* third place to remember to update when adding a new plugin).
* - The `docmd` namespace in each package's package.json is the
* canonical source. This script is a thin reader.
*
* Why not read package.json's directly at runtime:
* - The published `@docmd/api` package doesn't carry the source
* `package.json` files of every plugin. Bundling a generated
* registry keeps the loader dependency-free and fast.
* - A build-time check can fail loudly if a package forgot a
* required field; a runtime check just warns.
*
* Run via: `node scripts/build-plugin-registry.mjs` (from the monorepo
* root), or automatically as a `prebuild` step of `@docmd/api`.
* --------------------------------------------------------------------
*/
import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = resolve(__dirname, '..');
const SCAN_DIRS = [
{ dir: join(ROOT, 'packages', 'plugins'), kind: 'plugin', scope: 'plugin' },
{ dir: join(ROOT, 'packages', 'templates'), kind: 'template', scope: 'template' },
{ dir: join(ROOT, 'packages', 'engines'), kind: 'engine', scope: 'engine' },
];
// Packages explicitly excluded from the registry.
const EXCLUDE = new Set(['@docmd/plugin-installer', '@docmd/engine-rust-binaries']);
const errors = [];
const registry = {
$meta: {
generatedAt: new Date().toISOString(),
generator: 'scripts/build-plugin-registry.mjs',
packageCount: 0,
},
};
/**
* Derive the user-facing key (what goes in `docmd.config.json#plugins.<key>`)
* from a package's npm name. `@docmd/plugin-search` → `search`,
* `@docmd/template-summer` → `summer`, `@docmd/engine-js` → `js`.
*
* If the package author declared a custom `docmd.key` we honour that instead.
*/
function deriveKey(pkgName, docmd) {
if (docmd && typeof docmd.key === 'string' && docmd.key.length > 0) {
return docmd.key;
}
const match = pkgName.match(/^@docmd\/(?:plugin|template|engine)-(.+)$/);
if (!match) {
errors.push(`Cannot derive key for "${pkgName}" — name must match @docmd/{plugin|template|engine}-<key>.`);
return '';
}
return match[1];
}
/**
* Derive capabilities from the package's JS descriptor as a fallback if the
* author didn't declare them in package.json#docmd.capabilities. We do a
* lightweight regex scan — the loader does the real validation.
*/
function readCapabilitiesFromJs(pkgDir) {
const candidates = [
join(pkgDir, 'src', 'index.ts'),
join(pkgDir, 'src', 'index.js'),
join(pkgDir, 'dist', 'index.js'),
];
for (const file of candidates) {
if (!existsSync(file)) continue;
const src = readFileSync(file, 'utf8');
const m = src.match(/capabilities\s*:\s*\[([^\]]*)\]/);
if (m) {
return m[1]
.split(',')
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
.filter(Boolean);
}
}
return [];
}
let count = 0;
for (const { dir, kind } of SCAN_DIRS) {
if (!existsSync(dir)) continue;
if (!statSync(dir).isDirectory()) continue;
for (const name of readdirSync(dir)) {
const pkgPath = join(dir, name, 'package.json');
if (!existsSync(pkgPath)) continue;
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
if (EXCLUDE.has(pkg.name)) continue;
// Skip non-loadable subpackages (data-only, build helpers, etc.)
if (!pkg.main && !pkg.exports) continue;
const docmd = pkg.docmd;
const key = deriveKey(pkg.name, docmd);
if (!key) continue;
// Determine capabilities. Plugins + templates must declare them.
// Engines don't participate in the hook system, so no capabilities.
let capabilities = [];
if (kind !== 'engine') {
if (docmd && Array.isArray(docmd.capabilities) && docmd.capabilities.length > 0) {
capabilities = docmd.capabilities.slice();
} else {
// Fallback: try to read from the JS descriptor. If we can't find
// any, the build fails loudly — the author must declare them.
capabilities = readCapabilitiesFromJs(join(dir, name));
if (capabilities.length === 0) {
errors.push(
`${pkg.name}: missing "docmd.capabilities" in package.json. ` +
`Add a "docmd" namespace with the hook capabilities this package implements.`
);
continue;
}
if (kind === 'template' && !capabilities.includes('template')) {
capabilities = ['template', ...capabilities];
}
}
}
const entry = {
package: pkg.name,
description: pkg.description || '',
configKey: key,
defaultConfig: '{}',
kind: (docmd && docmd.kind) || kind,
displayName: (docmd && docmd.displayName) || name,
tagline: (docmd && docmd.tagline) || pkg.description || '',
capabilities,
};
if (docmd && docmd.preview) entry.preview = docmd.preview;
registry[key] = entry;
count++;
}
}
registry.$meta.packageCount = count;
if (errors.length) {
console.error('✗ build-plugin-registry failed:');
for (const err of errors) console.error(` - ${err}`);
process.exit(1);
}
const outDir = resolve(ROOT, 'packages', 'api', 'registry');
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
const outFile = join(outDir, 'plugins.generated.json');
writeFileSync(outFile, JSON.stringify(registry, null, 2) + '\n');
console.log(`✓ Wrote ${count} entries to ${outFile}`);