feat(readme): fail-closed generation with topic/kind groups, V1/V2 badges, counts and recently added

A single unparseable entry used to blank a whole README category and still exit 0. generate-readme and export-json now validate the whole catalog and refuse to write on any problem, check that every entry renders exactly once, and escape contributor text. Plugins are grouped by primary topic and other categories by kind; plugins show V2 / V1 only / unverified; section headers show counts; a Recently Added table comes from git history. registry.json gains kind, topics, also, compat, repoPath and npmPackage (existing fields unchanged).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
John SmithandClaude Opus 5.5 committed 2026-10-17 13:00:22 -04:00
1 parent 3a6d104fcf
commit bb2827524b
5 files changed
+250 -242

No files matched your search

+38 -99
View File
@@ -4,14 +4,13 @@
* Usage:
* node scripts/export-json.js --pretty --output dist/registry.json
* node scripts/export-json.js --pretty > dist/registry.json
*
* Fails without writing anything if any entry is invalid.
*/
const fs = require('fs');
const path = require('path');
const { readYamlDir, slugify } = require('./utils/yaml');
const { validateEntry, formatValidationErrors } = require('./utils/validation');
const CATEGORIES = ['plugins', 'themes', 'agents', 'projects', 'resources'];
const { checkCatalog, formatProblems, loadCatalog } = require('./utils/catalog');
function parseArgs(args) {
const outputFlagIndex = args.findIndex((arg) => arg === '--output');
@@ -22,16 +21,6 @@ function parseArgs(args) {
return { outputPath, pretty };
}
function buildProductId(entry) {
if (entry._fileName) {
return entry._fileName;
}
if (entry.name) {
return slugify(entry.name);
}
return '';
}
function sanitizeArray(value, fallback) {
if (Array.isArray(value) && value.length > 0) {
return value;
@@ -39,27 +28,28 @@ function sanitizeArray(value, fallback) {
return fallback;
}
function mapEntry(entry, category) {
const productId = buildProductId(entry);
if (!productId) {
return null;
}
const output = {
productId,
// type, productId and the original fields keep their names for downstream consumers (opencode.cafe);
// newer fields are additive.
function mapEntry({ category, id, data }) {
return {
productId: id,
type: category,
displayName: entry.name,
repoUrl: entry.repo,
tagline: entry.tagline,
description: entry.description,
scope: sanitizeArray(entry.scope, ['global']),
tags: sanitizeArray(entry.tags, []),
homepageUrl: entry.homepage,
installation: entry.installation,
minVersion: entry.min_version
displayName: data.name,
repoUrl: data.repo,
repoPath: data.path,
npmPackage: data.npm,
tagline: data.tagline,
description: data.description,
kind: data.kind,
also: sanitizeArray(data.also, undefined),
topics: sanitizeArray(data.topics, []),
compat: data.compat || (category === 'plugins' ? 'unknown' : 'n/a'),
scope: sanitizeArray(data.scope, ['global']),
tags: sanitizeArray(data.tags, []),
homepageUrl: data.homepage,
installation: data.installation,
minVersion: data.min_version
};
return output;
}
function stripUndefined(data) {
@@ -72,87 +62,36 @@ function stripUndefined(data) {
return cleaned;
}
async function loadEntries() {
const results = [];
const errors = [];
for (const category of CATEGORIES) {
const categoryPath = path.join(__dirname, '../data', category);
let entries = [];
try {
entries = await readYamlDir(categoryPath);
} catch (err) {
if (err.code === 'ENOENT') {
continue;
}
throw err;
}
for (const entry of entries) {
const result = validateEntry(entry, entry._filePath || categoryPath);
if (!result.valid) {
errors.push(formatValidationErrors(result));
continue;
}
const mapped = mapEntry(entry, category);
if (mapped) {
results.push(stripUndefined(mapped));
}
}
}
return { results, errors };
}
async function main() {
function main() {
const { outputPath, pretty } = parseArgs(process.argv.slice(2));
const writeToStdout = !outputPath;
const log = (...args) => {
if (writeToStdout) {
console.error(...args);
} else {
console.log(...args);
}
};
const log = (...args) => (outputPath ? console.log(...args) : console.error(...args));
const { results, errors } = await loadEntries();
results.sort((a, b) => {
const typeCompare = a.type.localeCompare(b.type);
if (typeCompare !== 0) {
return typeCompare;
}
return a.productId.localeCompare(b.productId);
});
if (errors.length > 0) {
for (const error of errors) {
log(error);
}
log(`\n⚠️ Skipped ${errors.length} invalid entr${errors.length === 1 ? 'y' : 'ies'}.`);
const { entries, errors } = loadCatalog();
const problems = [...errors, ...checkCatalog(entries)];
if (problems.length > 0) {
log(formatProblems(problems));
throw new Error(`${problems.length} validation problem(s); nothing was exported`);
}
const results = entries.map(e => stripUndefined(mapEntry(e)));
results.sort((a, b) => a.type.localeCompare(b.type) || a.productId.localeCompare(b.productId));
const json = JSON.stringify(results, null, pretty ? 2 : 0);
if (outputPath) {
const outputDir = path.dirname(outputPath);
if (outputDir && outputDir !== '.') {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputPath, json);
fs.writeFileSync(outputPath, json + '\n');
log(`Saved ${results.length} entries to ${outputPath}`);
} else {
process.stdout.write(json);
}
if (errors.length > 0) {
process.exit(1);
}
}
main().catch((err) => {
try {
main();
} catch (err) {
console.error('Export failed:', err.message);
process.exit(1);
});
}
+91 -120
View File
@@ -2,153 +2,124 @@
/**
* Generate README.md from YAML files
* Reads all category YAML files, validates them, sorts alphabetically,
* generates HTML, and writes the final README.md
* Loads and validates the whole catalog, groups each category (plugins by primary topic, other
* categories by kind), and writes README.md. Fails without writing anything if any entry is invalid,
* so a single bad file can never publish a README with missing sections.
*/
const { CATEGORIES, KINDS, KIND_LABELS, TOPICS, TOPIC_LABELS, V1_SUNSET, checkCatalog, formatProblems, loadCatalog } = require('./utils/catalog');
const { addedDates } = require('./utils/history');
const { escapeHtml, readTemplate, replacePlaceholder, generateEntryHtml, writeReadme } = require('./utils/template');
const path = require('path');
const { readYamlDir } = require('./utils/yaml');
const { validateEntry, formatValidationErrors } = require('./utils/validation');
const { readTemplate, replacePlaceholder, generateEntryHtml, writeReadme } = require('./utils/template');
const RECENT_COUNT = 15;
const README_WARN_KB = 450;
// Category processing order
const CATEGORIES = ['plugins', 'themes', 'agents', 'projects', 'resources'];
// Placeholder names for each category
const CATEGORY_PLACEHOLDERS = {
plugins: 'PLUGINS',
themes: 'THEMES',
agents: 'AGENTS',
projects: 'PROJECTS',
resources: 'RESOURCES'
// How each category is subdivided in the README
const GROUP_BY = {
plugins: { field: 'topic', order: TOPICS, labels: TOPIC_LABELS },
themes: null,
agents: { field: 'kind', order: KINDS, labels: KIND_LABELS },
projects: { field: 'kind', order: KINDS, labels: KIND_LABELS },
resources: { field: 'kind', order: KINDS, labels: KIND_LABELS }
};
const byName = (a, b) => a.data.name.toLowerCase().localeCompare(b.data.name.toLowerCase());
/**
* Generate HTML for a single category
* @param {string} categoryName - Category directory name
* @returns {Promise<string>} Generated HTML for all entries in the category
* @param {string} category - Category directory name
* @param {object[]} entries - Entries in this category
* @returns {string}
*/
async function generateCategorySection(categoryName) {
const categoryPath = path.join(__dirname, '../data', categoryName);
function generateCategorySection(category, entries) {
const grouping = GROUP_BY[category];
const render = list => list.sort(byName).map(e => generateEntryHtml(e.data, category)).join('\n\n');
if (!grouping) return render(entries);
let entries = [];
let errors = [];
try {
entries = await readYamlDir(categoryPath);
} catch (err) {
// If directory doesn't exist, return empty string
if (err.code === 'ENOENT') {
return { html: '', count: 0, errors: [] };
}
// Re-throw other errors
throw err;
}
// Validate and filter entries
const validEntries = [];
const groups = new Map(grouping.order.map(key => [key, []]));
for (const entry of entries) {
const result = validateEntry(entry, entry._filePath || path.join(categoryPath, `${entry.name || 'unknown'}.yaml`));
if (result.valid) {
validEntries.push(entry);
} else {
const errorMsg = formatValidationErrors(result);
errors.push(errorMsg);
console.error(errorMsg);
}
const key = grouping.field === 'topic' ? entry.data.topics[0] : entry.data.kind;
groups.get(key).push(entry);
}
return [...groups]
.filter(([, list]) => list.length > 0)
.map(([key, list]) => `<h4>${grouping.labels[key]} <sub>(${list.length})</sub></h4>\n\n${render(list)}`)
.join('\n\n<br>\n\n');
}
// Sort alphabetically by name (case-insensitive)
validEntries.sort((a, b) => {
const nameA = (a.name || '').toLowerCase();
const nameB = (b.name || '').toLowerCase();
return nameA.localeCompare(nameB);
});
/**
* One-line catalog summary under the header.
* @param {object[]} entries
* @returns {string}
*/
function generateStats(entries) {
const plugins = entries.filter(e => e.category === 'plugins');
const count = compat => plugins.filter(e => e.data.compat === compat).length;
return `<p><b>${entries.length}</b> entries &nbsp;•&nbsp; <b>${plugins.length}</b> plugins: ` +
`<b>${count('v2')}</b> <code>V2</code>, <b>${count('v1')}</b> <code>V1 only</code> ` +
`(removed after ${V1_SUNSET}), <b>${count('unknown')}</b> <code>unverified</code></p>`;
}
// Generate HTML for each entry
const htmlParts = validEntries.map(entry => generateEntryHtml(entry));
// Join with double newlines for proper spacing
const html = htmlParts.join('\n\n');
return { html, count: validEntries.length, errors };
/**
* Most recently added entries, from git history.
* @param {object[]} entries
* @returns {string}
*/
function generateRecent(entries) {
const dates = addedDates();
if (dates.size === 0) return '<p><i>History unavailable.</i></p>';
const recent = entries
.filter(e => dates.has(e.file))
.sort((a, b) => dates.get(b.file).localeCompare(dates.get(a.file)) || byName(a, b))
.slice(0, RECENT_COUNT);
const rows = recent.map(e => `| **[${escapeHtml(e.data.name).replace(/\|/g, '\\|')}](${e.data.repo})** | ${e.category} | ${escapeHtml(e.data.tagline).replace(/\|/g, '\\|')} |`);
return ['| Name | Category | Tagline |', '| :--- | :--- | :--- |', ...rows].join('\n');
}
/**
* Main function to generate the README
*/
async function main() {
function main() {
console.log('Starting README generation...\n');
// Read template
let template;
try {
template = readTemplate();
console.log('Template loaded successfully');
} catch (err) {
throw new Error(`Failed to read template: ${err.message}`);
const { entries, errors } = loadCatalog();
const problems = [...errors, ...checkCatalog(entries)];
if (problems.length > 0) {
console.error(formatProblems(problems));
throw new Error(`${problems.length} validation problem(s); README.md was not written`);
}
// Process each category in order
const results = {};
let totalEntries = 0;
let allErrors = [];
let content = readTemplate();
for (const category of CATEGORIES) {
const placeholder = CATEGORY_PLACEHOLDERS[category];
console.log(`Processing ${category}...`);
const list = entries.filter(e => e.category === category);
const placeholder = category.toUpperCase();
content = replacePlaceholder(content, placeholder, generateCategorySection(category, list));
content = replacePlaceholder(content, `COUNT_${placeholder}`, String(list.length));
console.log(` - ${category}: ${list.length} entries`);
}
content = replacePlaceholder(content, 'STATS', generateStats(entries));
content = replacePlaceholder(content, 'RECENT', generateRecent(entries));
try {
const result = await generateCategorySection(category);
results[placeholder] = result.html;
totalEntries += result.count;
allErrors = allErrors.concat(result.errors);
if (result.count > 0) {
console.log(` - Found ${result.count} valid entries`);
} else {
console.log(` - No entries found`);
}
} catch (err) {
console.error(` - Error processing ${category}: ${err.message}`);
results[placeholder] = '';
}
// Every entry must appear exactly once
const rendered = (content.match(/<details>\n {2}<summary>/g) || []).length;
if (rendered !== entries.length) {
throw new Error(`rendered ${rendered} entries but the catalog has ${entries.length}; README.md was not written`);
}
if (/\{\{[A-Z_]+\}\}/.test(content)) {
throw new Error(`unreplaced placeholder ${content.match(/\{\{[A-Z_]+\}\}/)[0]}; README.md was not written`);
}
// Replace each placeholder in template
let content = template;
for (const [placeholder, html] of Object.entries(results)) {
content = replacePlaceholder(content, placeholder, html);
writeReadme(content);
console.log(`\n✅ Generated README.md with ${entries.length} entries across ${CATEGORIES.length} categories`);
const kb = Math.round(Buffer.byteLength(content) / 1024);
if (kb > README_WARN_KB) {
console.log(`::warning title=README size::README.md is ${kb} KB; GitHub stops rendering large markdown files. Consider trimming descriptions or moving the full list to the site.`);
}
// Write final README
try {
writeReadme(content);
console.log('\nREADME.md written successfully');
} catch (err) {
throw new Error(`Failed to write README.md: ${err.message}`);
}
// Log summary
const errorCount = allErrors.length;
if (errorCount > 0) {
console.log(`\n⚠️ Generated README.md with ${totalEntries} entries across ${CATEGORIES.length} categories`);
console.log(` ${errorCount} validation error(s) were logged (affected entries were skipped)`);
} else {
console.log(`\n✅ Generated README.md with ${totalEntries} entries across ${CATEGORIES.length} categories`);
}
return { totalEntries, categoryCount: CATEGORIES.length, errorCount };
}
// Execute main function
main()
.then(({ totalEntries, categoryCount, errorCount }) => {
process.exit(errorCount > 0 ? 1 : 0);
})
.catch(err => {
console.error('\n❌ Generation failed:', err.message);
process.exit(1);
});
try {
main();
} catch (err) {
console.error('\n❌ Generation failed:', err.message);
process.exit(1);
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Git history helpers. Requires a full clone (actions/checkout with fetch-depth: 0);
* in a shallow clone every file looks like it was added in the oldest fetched commit.
*/
const { execFileSync } = require('child_process');
const path = require('path');
const ROOT = path.join(__dirname, '../..');
/**
* Date each data file was first added, newest commits first.
* @returns {Map<string, string>} "data/plugins/x.yaml" -> ISO date; empty when git is unavailable
*/
function addedDates() {
const dates = new Map();
let out = '';
try {
out = execFileSync('git', ['log', '--diff-filter=A', '--name-only', '--format=@%cI', '--', 'data/'], {
cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore']
});
} catch {
return dates;
}
let current = null;
for (const line of out.split('\n')) {
if (line.startsWith('@')) current = line.slice(1);
else if (line.endsWith('.yaml') && current && !dates.has(line)) dates.set(line, current);
}
return dates;
}
module.exports = { addedDates };
+60 -12
View File
@@ -5,6 +5,12 @@
const fs = require('fs');
const path = require('path');
const COMPAT_BADGES = {
v2: '<code>V2</code>',
v1: '<code>V1 only</code>',
unknown: '<code>unverified</code>'
};
/**
* Read the README template
* @returns {string} Template content
@@ -23,15 +29,38 @@ function readTemplate() {
*/
function replacePlaceholder(template, placeholder, content) {
const pattern = new RegExp(`\\{\\{${placeholder}\\}\\}`, 'g');
return template.replace(pattern, content);
return template.replace(pattern, () => content);
}
/**
* Escape text for use inside HTML (entries are untrusted contributor text).
* @param {string} text
* @returns {string}
*/
function escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* URL of the entry's code, including the monorepo subfolder when set.
* @param {object} entry - Parsed YAML data
* @returns {string}
*/
function sourceUrl(entry) {
return entry.path ? `${entry.repo.replace(/\/+$/, '')}/tree/HEAD/${entry.path}` : entry.repo;
}
/**
* Generate HTML for a single entry
* @param {object} entry - Parsed YAML data
* @param {string} category - Category directory name
* @returns {string} HTML string for the details element
*/
function generateEntryHtml(entry) {
function generateEntryHtml(entry, category) {
// Determine link text based on URL type first
let linkText = '🔗 <b>View Repository</b>';
if (entry.repo.includes('gist.github.com')) {
@@ -46,7 +75,7 @@ function generateEntryHtml(entry) {
const isDiscussion = entry.repo.includes('/discussions/');
const repoMatch = entry.repo.match(/github\.com\/(?!gist\.)([^\/]+)\/([^\/]+)/);
let summaryContent = `<b>${entry.name}</b>`;
let summaryContent = `<b>${escapeHtml(entry.name)}</b>`;
// Add star badge if it's a GitHub repo (not a gist or discussion)
if (repoMatch && !isGist && !isDiscussion) {
@@ -56,14 +85,31 @@ function generateEntryHtml(entry) {
summaryContent += ` <img src="${starBadge}" height="14"/>`;
}
summaryContent += ` - <i>${entry.tagline}</i>`;
// Compatibility badge: always for plugins, otherwise only when the entry uses the OpenCode API
const compat = entry.compat || (category === 'plugins' ? 'unknown' : 'n/a');
if (COMPAT_BADGES[compat] && (category === 'plugins' || compat !== 'unknown')) {
summaryContent += ` ${COMPAT_BADGES[compat]}`;
}
if (entry.also && entry.also.includes('mcp-server')) {
summaryContent += ' <code>MCP</code>';
}
summaryContent += ` - <i>${escapeHtml(entry.tagline)}</i>`;
const links = [`<a href="${escapeHtml(sourceUrl(entry))}">${linkText}</a>`];
if (entry.npm) {
links.push(`<a href="https://www.npmjs.com/package/${escapeHtml(entry.npm)}">📦 <b>npm</b></a>`);
}
if (entry.homepage && !/npmjs\.com\/package\//.test(entry.homepage)) {
links.push(`<a href="${escapeHtml(entry.homepage)}">📖 <b>Docs</b></a>`);
}
return `<details>
<summary>${summaryContent}</summary>
<blockquote>
${entry.description}
${escapeHtml(entry.description)}
<br><br>
<a href="${entry.repo}">${linkText}</a>
${links.join(' &nbsp;•&nbsp; ')}
</blockquote>
</details>`;
}
@@ -77,9 +123,11 @@ function writeReadme(content) {
fs.writeFileSync(readmePath, content, 'utf8');
}
module.exports = {
readTemplate,
replacePlaceholder,
generateEntryHtml,
writeReadme
module.exports = {
escapeHtml,
readTemplate,
replacePlaceholder,
generateEntryHtml,
sourceUrl,
writeReadme
};
+28 -11
View File
@@ -1,3 +1,4 @@
<!-- GENERATED FILE: do not edit README.md directly. Add or edit data/<category>/<name>.yaml (see contributing.md); README.md is rebuilt from templates/README.template.md on every merge. -->
<!-- HEADER -->
<div align="center">
@@ -38,9 +39,17 @@
> **Active Hard Fork**: `Heretek-AI/awesome-opencode` is the actively maintained community continuation of `awesome-opencode`.
> All original upstream issues, pull requests, and discussions are preserved and imported. Submissions and PRs are actively reviewed!
{{STATS}}
<p>
<a href="https://heretek-ai.github.io/awesome-opencode/"><b>🔎 Search &amp; filter the catalog</b></a>
&nbsp;•&nbsp;
<a href="https://github.com/Heretek-AI/awesome-opencode/issues/new?template=add-entry.yml"><b>➕ Submit an entry</b></a>
</p>
<br>
[**OFFICIAL**](#official) • [**PLUGINS**](#plugins) • [**THEMES**](#themes) • [**AGENTS**](#agents) • [**PROJECTS**](#projects) • [**RESOURCES**](#resources)
[**OFFICIAL**](#official) • [**NEW**](#recently-added) • [**PLUGINS**](#plugins) • [**THEMES**](#themes) • [**AGENTS**](#agents) • [**PROJECTS**](#projects) • [**RESOURCES**](#resources)
<br>
<hr>
@@ -62,16 +71,24 @@
<br>
<div id="recently-added"></div>
<h3>🆕 Recently Added</h3>
{{RECENT}}
<br>
<div id="plugins"></div>
<details open>
<summary><strong>🧩 PLUGINS</strong></summary>
<summary><strong>🧩 PLUGINS ({{COUNT_PLUGINS}})</strong></summary>
<br>
{{PLUGINS}}
<br>
<a href="https://github.com/Heretek-AI/awesome-opencode/blob/main/contributing.md"><b>➕ Add a Plugin via PR</b></a>
<a href="https://github.com/Heretek-AI/awesome-opencode/issues/new?template=add-entry.yml"><b>➕ Add a Plugin</b></a>
</details>
<br>
@@ -79,13 +96,13 @@
<div id="themes"></div>
<details>
<summary><strong>🎨 THEMES</strong></summary>
<summary><strong>🎨 THEMES ({{COUNT_THEMES}})</strong></summary>
<br>
{{THEMES}}
<br>
<a href="https://github.com/Heretek-AI/awesome-opencode/blob/main/contributing.md"><b>➕ Add a Theme via PR</b></a>
<a href="https://github.com/Heretek-AI/awesome-opencode/issues/new?template=add-entry.yml"><b>➕ Add a Theme</b></a>
</details>
<br>
@@ -93,13 +110,13 @@
<div id="agents"></div>
<details>
<summary><strong>🤖 AGENTS</strong></summary>
<summary><strong>🤖 AGENTS ({{COUNT_AGENTS}})</strong></summary>
<br>
{{AGENTS}}
<br>
<a href="https://github.com/Heretek-AI/awesome-opencode/blob/main/contributing.md"><b>➕ Add an Agent via PR</b></a>
<a href="https://github.com/Heretek-AI/awesome-opencode/issues/new?template=add-entry.yml"><b>➕ Add an Agent</b></a>
</details>
<br>
@@ -107,13 +124,13 @@
<div id="projects"></div>
<details>
<summary><strong>🛠 PROJECTS</strong></summary>
<summary><strong>🛠 PROJECTS ({{COUNT_PROJECTS}})</strong></summary>
<br>
{{PROJECTS}}
<br>
<a href="https://github.com/Heretek-AI/awesome-opencode/blob/main/contributing.md"><b>➕ Add a Project via PR</b></a>
<a href="https://github.com/Heretek-AI/awesome-opencode/issues/new?template=add-entry.yml"><b>➕ Add a Project</b></a>
</details>
<br>
@@ -121,13 +138,13 @@
<div id="resources"></div>
<details>
<summary><strong>📚 RESOURCES</strong></summary>
<summary><strong>📚 RESOURCES ({{COUNT_RESOURCES}})</strong></summary>
<br>
{{RESOURCES}}
<br>
<a href="https://github.com/Heretek-AI/awesome-opencode/blob/main/contributing.md"><b>➕ Add a Resource via PR</b></a>
<a href="https://github.com/Heretek-AI/awesome-opencode/issues/new?template=add-entry.yml"><b>➕ Add a Resource</b></a>
</details>
<br><br>