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

+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
};