Files
John SmithandClaude Opus 5.5 56fad94c34 feat(schema): add kind, topics, compat, npm and path; reject unknown fields and non-root repo URLs
Entries gain a fixed kind and 1-3 topics (replacing 374 one-off tags as the categorization axis), an OpenCode API compat field (required for plugins), and npm/path for packages and monorepo folders. repo must be a repository root; unknown fields such as install: or repository: are rejected. Cross-entry rules (one entry per repo+path, kind must fit the category, no placeholder owners or mojibake) live in scripts/utils/catalog.js, and validate.js now always loads the whole catalog and fails on unparseable YAML. Adds npm run fmt for canonical formatting and drops the unused glob dependency.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:00:22 -04:00

251 lines
8.0 KiB
JavaScript

/**
* Catalog loading and cross-entry rules for awesome-opencode.
*
* Every consumer (validate, generate-readme, export-json, CI checks) loads entries through
* loadCatalog() so a single unparseable file is reported as an error instead of silently
* dropping a whole category.
*/
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const { validateEntry } = require('./validation');
const schema = require('../../data/schema.json');
const DATA_DIR = path.join(__dirname, '../../data');
// Category processing order (README section order)
const CATEGORIES = ['plugins', 'themes', 'agents', 'projects', 'resources'];
// Plugins still on the V1 API (compat: v1) are removed from the catalog after this date
const V1_SUNSET = '2027-01-01';
const KINDS = schema.properties.kind.enum;
const TOPICS = schema.properties.topics.items.enum;
// Which kinds may live in which category directory
const CATEGORY_KINDS = {
plugins: ['plugin'],
themes: ['theme'],
agents: ['agent', 'skill'],
projects: ['client', 'tool', 'mcp-server', 'skill', 'agent', 'fork'],
resources: ['guide', 'config', 'mcp-server', 'skill', 'tool']
};
const TOPIC_LABELS = {
memory: 'Memory',
context: 'Context & Tokens',
orchestration: 'Multi-Agent & Orchestration',
workflow: 'Workflows & Planning',
providers: 'Providers, Auth & Routing',
cost: 'Cost & Usage',
notifications: 'Notifications',
sessions: 'Sessions & History',
'code-quality': 'Code Review & Quality',
security: 'Security & Sandboxing',
git: 'Git & GitHub',
remote: 'Remote & Mobile',
web: 'Web & Browser',
ui: 'UI & TUI',
voice: 'Voice',
media: 'Media & Documents',
docs: 'Docs & Research',
environment: 'Environment & Setup',
observability: 'Observability',
integrations: 'Integrations',
'code-intel': 'Code Intelligence',
prompting: 'Prompting & Skills',
domain: 'Domain-Specific'
};
const KIND_LABELS = {
plugin: 'Plugins',
theme: 'Themes',
agent: 'Agents',
skill: 'Skills',
'mcp-server': 'MCP Servers',
client: 'Clients & GUIs',
tool: 'Tools & Utilities',
config: 'Configs & Starters',
guide: 'Guides',
fork: 'Forks'
};
const FILENAME_RE = /^[a-z0-9]+(?:[-.][a-z0-9]+)*\.yaml$/;
// Contributor-facing wording for schema pattern failures
const PATTERN_HINTS = {
'/repo': 'repo must be the repository root, e.g. https://github.com/owner/repo (no /tree/ or /blob/ paths, npm pages or websites; use `path` for a folder inside a monorepo)',
'/path': 'path must be a relative folder inside the repo, e.g. packages/my-plugin',
'/npm': 'npm must be a package name, e.g. opencode-my-plugin or @scope/opencode-my-plugin',
'/min_version': 'min_version must be semver, e.g. "2.0.0"'
};
const MOJIBAKE_RE = /鈥|â€|Ã[\u0080-¿]/;
const PLACEHOLDER_OWNER_RE =/^(your[-_]?(user(name)?|org|name)|user(name)?|owner|example|org|me)$/i;
/**
* Parse a repo URL into { host, owner, name } (gists return owner/id).
* @param {string} url
* @returns {{host: string, owner: string, name: string}|null}
*/
function parseRepoUrl(url) {
const match = String(url || '').match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/#?]+)/);
if (!match) return null;
return { host: match[1].toLowerCase(), owner: match[2], name: match[3].replace(/\.git$/, '') };
}
/**
* Canonical identity of an entry's target: lowercased host/owner/repo plus optional path.
* @param {object} data - Entry data
* @returns {string}
*/
function canonicalKey(data) {
const parsed = parseRepoUrl(data.repo);
const base = parsed
? `${parsed.host}/${parsed.owner}/${parsed.name}`.toLowerCase()
: String(data.repo || '').toLowerCase().replace(/\/+$/, '');
const sub = (data.path || '').replace(/^\/+|\/+$/g, '').toLowerCase();
return sub ? `${base}#${sub}` : base;
}
/**
* Load every entry in data/<category>/*.yaml.
* @param {object} [options]
* @param {string} [options.dataDir] - Override the data directory
* @returns {{entries: object[], errors: {file: string, message: string}[]}}
*/
function loadCatalog({ dataDir = DATA_DIR } = {}) {
const entries = [];
const errors = [];
for (const category of CATEGORIES) {
const dir = path.join(dataDir, category);
if (!fs.existsSync(dir)) continue;
for (const fileName of fs.readdirSync(dir).filter(f => f.endsWith('.yaml')).sort()) {
const file = path.join('data', category, fileName);
try {
const data = yaml.load(fs.readFileSync(path.join(dir, fileName), 'utf8'));
if (!data || typeof data !== 'object' || Array.isArray(data)) {
errors.push({ file, message: 'file is empty or not a YAML mapping' });
continue;
}
entries.push({ category, id: fileName.replace(/\.yaml$/, ''), file, data });
} catch (err) {
errors.push({ file, message: `invalid YAML: ${err.reason || err.message}` });
}
}
}
return { entries, errors };
}
/**
* Rules for a single entry: schema, kind/category fit, filename, placeholder repos.
* @param {object} entry - { category, id, file, data }
* @returns {string[]} Error messages
*/
function checkEntry(entry) {
const messages = [];
const result = validateEntry(entry.data, entry.file);
if (!result.valid) {
for (const err of result.errors) {
if (err.keyword === 'pattern' && PATTERN_HINTS[err.path]) {
messages.push(PATTERN_HINTS[err.path]);
continue;
}
const detail = err.keyword === 'additionalProperties'
? `unknown field "${err.params.additionalProperty}"`
: err.keyword === 'enum'
? `${err.message}: ${err.params.allowedValues.join(', ')}`
: err.message;
messages.push(`${err.path === '/' ? '' : err.path + ': '}${detail}`);
}
}
if (!FILENAME_RE.test(path.basename(entry.file))) {
messages.push('filename must be kebab-case, e.g. my-plugin.yaml');
}
const allowed = CATEGORY_KINDS[entry.category];
if (entry.data.kind && allowed && !allowed.includes(entry.data.kind)) {
messages.push(`kind "${entry.data.kind}" does not belong in data/${entry.category}/ (allowed: ${allowed.join(', ')})`);
}
if (entry.category === 'plugins' && !entry.data.compat) {
messages.push('plugins must declare compat (v2, v1 or unknown); CI verifies it');
}
for (const field of ['name', 'tagline', 'description']) {
if (MOJIBAKE_RE.test(entry.data[field] || '')) {
messages.push(`${field} contains mis-encoded characters (e.g. "鈥?" or "—" instead of "—"); re-save the file as UTF-8`);
}
}
const parsed = parseRepoUrl(entry.data.repo);
if (parsed && PLACEHOLDER_OWNER_RE.test(parsed.owner)) {
messages.push(`repo owner "${parsed.owner}" looks like a placeholder`);
}
return messages;
}
/**
* Validate the whole catalog: per-entry rules plus cross-entry uniqueness.
* @param {object[]} entries
* @returns {{file: string, message: string}[]}
*/
function checkCatalog(entries) {
const problems = [];
const byKey = new Map();
for (const entry of entries) {
for (const message of checkEntry(entry)) {
problems.push({ file: entry.file, message });
}
if (!entry.data.repo) continue;
const key = canonicalKey(entry.data);
if (byKey.has(key)) {
problems.push({
file: entry.file,
message: `duplicate of ${byKey.get(key)} (same repo${entry.data.path ? ' and path' : ''}); use \`path\` for separate packages in one monorepo`
});
} else {
byKey.set(key, entry.file);
}
}
return problems;
}
/**
* Format problems for terminal output.
* @param {{file: string, message: string}[]} problems
* @returns {string}
*/
function formatProblems(problems) {
const byFile = new Map();
for (const { file, message } of problems) {
if (!byFile.has(file)) byFile.set(file, []);
byFile.get(file).push(message);
}
return [...byFile].map(([file, messages]) => `✗ ${file}\n${messages.map(m => ` - ${m}`).join('\n')}`).join('\n');
}
module.exports = {
CATEGORIES,
CATEGORY_KINDS,
DATA_DIR,
KINDS,
KIND_LABELS,
TOPICS,
TOPIC_LABELS,
V1_SUNSET,
canonicalKey,
checkCatalog,
checkEntry,
formatProblems,
loadCatalog,
parseRepoUrl
};