3d1ed15b6d
* feat(registry): move community registry source of truth into the public repo The site's registry page says "Lägg till en egen" and links here, but the MDX entries lived in the private website repo, so an external contributor had no path to open the PR we were inviting (found by the first person who tried). This makes the invitation real: - registry/entries/ + registry/authors/ hold the 20 existing entries and 2 author profiles, migrated verbatim from the website repo, which now syncs FROM this directory instead of owning the content - registry/README.md documents the frontmatter convention and the flow - scripts/validate-registry.ts (npm run validate:registry, wired into core-build) checks structure and rejects JSX/import/export in bodies: the site renders entries through MDX, which would execute those inside the website build - CONTRIBUTING.md points at the registry for listing community work Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> * fix(registry): close MDX-safety gaps and correct six compliance claims from PR review Review bot findings on #1458, both verified and addressed: - The body safety gate only rejected capitalized JSX tags, but MDX also evaluates lowercase HTML tags (<div>, <img onerror=...>) and bare {...} expressions. The validator now rejects any raw tag and any brace outside fenced code and backtick inline code; literal tags in prose go in backticks. Verified: a crafted entry with all three bypasses fails, all existing content still passes. - Six factual errors in migrated entries, each checked against the skill sources in .claude/skills/ before editing (these were live on the site already): traktamente 2026 is 300 kr not 260; employer contributions for 66+ at year start (67+ from 2026) are 10.21% not "65+: 16.36%", and the under-18 0% claim is replaced with the documented 18-22 youth reduction; electronics reverse-charge threshold is 100 000 kr excl VAT per invoice not 250 000; half prisbasbelopp 2026 is 29 600 not 24 750; kostnadsställe is SIE dimension 1 not 7; SRU period suffixes encode the fiscal-year end range (P1 jan-apr, P2 maj-aug, P4 sep-dec) not fixed months. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> --------- Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
/**
|
|
* CI gate for the community registry (registry/).
|
|
*
|
|
* The registry is the public source of truth for the entries shown on
|
|
* gnubok.se/community/registry. Entries are contributed by PR from outside the
|
|
* team, so this gate has two jobs:
|
|
*
|
|
* 1. Structure: the website build consumes these files verbatim (they are
|
|
* synced into the site repo), so a malformed frontmatter field would take
|
|
* down the registry pages for everyone, not just the broken entry.
|
|
* 2. Safety: the site renders entry bodies through MDX. MDX evaluates JSX
|
|
* and import/export statements at build time, which would let a PR run
|
|
* arbitrary code inside the website build. Bodies must therefore be plain
|
|
* Markdown: no imports, no exports, no JSX elements, no script tags.
|
|
* Fenced code blocks are exempt (they are displayed, never evaluated).
|
|
*
|
|
* Usage:
|
|
* npx tsx scripts/validate-registry.ts # validate (CI)
|
|
* npx tsx scripts/validate-registry.ts --json # machine-readable summary
|
|
*/
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import yaml from 'js-yaml'
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
const ENTRIES_DIR = path.join(ROOT, 'registry', 'entries')
|
|
const AUTHORS_DIR = path.join(ROOT, 'registry', 'authors')
|
|
|
|
const KINDS = ['skill', 'mcp', 'workflow', 'app'] as const
|
|
const STATUSES = ['live', 'beta', 'archived'] as const
|
|
const LANGS = ['sv', 'en'] as const
|
|
const PERSONAS = ['founder', 'finance', 'byra', 'developer'] as const
|
|
const AUTHOR_KINDS = ['team', 'byra', 'founder', 'partner', 'community'] as const
|
|
|
|
const ENTRY_REQUIRED = [
|
|
'title',
|
|
'description',
|
|
'slug',
|
|
'kind',
|
|
'author',
|
|
'status',
|
|
'lang',
|
|
'personas',
|
|
'publishedAt',
|
|
'updatedAt',
|
|
] as const
|
|
|
|
/** An entry must give the reader at least one way to actually get the thing. */
|
|
const INSTALL_FIELDS = ['installCommand', 'downloadUrl', 'repoUrl', 'externalUrl'] as const
|
|
|
|
const URL_FIELDS = ['downloadUrl', 'repoUrl', 'externalUrl', 'url', 'logoUrl'] as const
|
|
|
|
interface Failure {
|
|
file: string
|
|
message: string
|
|
}
|
|
|
|
const failures: Failure[] = []
|
|
|
|
function listFiles(dir: string, exts: string[]): string[] {
|
|
if (!fs.existsSync(dir)) return []
|
|
return fs
|
|
.readdirSync(dir)
|
|
.filter((f) => exts.some((e) => f.endsWith(e)))
|
|
.filter((f) => !f.startsWith('_'))
|
|
.sort()
|
|
}
|
|
|
|
function parseFrontmatter(
|
|
file: string,
|
|
raw: string,
|
|
): { data: Record<string, unknown>; body: string } | null {
|
|
if (!raw.startsWith('---\n')) {
|
|
failures.push({ file, message: 'file must start with a --- frontmatter block' })
|
|
return null
|
|
}
|
|
const end = raw.indexOf('\n---\n', 4)
|
|
if (end === -1) {
|
|
failures.push({ file, message: 'frontmatter block is never closed with ---' })
|
|
return null
|
|
}
|
|
let data: unknown
|
|
try {
|
|
data = yaml.load(raw.slice(4, end))
|
|
} catch (e) {
|
|
failures.push({ file, message: `frontmatter is not valid YAML: ${(e as Error).message}` })
|
|
return null
|
|
}
|
|
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
failures.push({ file, message: 'frontmatter must be a YAML mapping' })
|
|
return null
|
|
}
|
|
return { data: data as Record<string, unknown>, body: raw.slice(end + 5) }
|
|
}
|
|
|
|
function isIsoDate(v: unknown): v is string {
|
|
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return false
|
|
const d = new Date(`${v}T00:00:00Z`)
|
|
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v
|
|
}
|
|
|
|
/**
|
|
* The MDX safety check. Lines inside fenced code blocks are display-only and
|
|
* skipped; everything else must be plain Markdown.
|
|
*/
|
|
function checkBodySafety(file: string, body: string): void {
|
|
let inFence = false
|
|
const lines = body.split('\n')
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i]
|
|
if (/^\s*(```|~~~)/.test(line)) {
|
|
inFence = !inFence
|
|
continue
|
|
}
|
|
if (inFence) continue
|
|
const stripped = line.replace(/`[^`]*`/g, '')
|
|
if (/^\s*import\s/.test(stripped) || /^\s*export\s/.test(stripped)) {
|
|
failures.push({
|
|
file,
|
|
message: `line ${i + 1}: import/export statements are not allowed (MDX would execute them in the site build)`,
|
|
})
|
|
}
|
|
// MDX treats ANY tag as JSX, lowercase HTML included (<div>, <img
|
|
// onerror=...>), and a bare {...} as a JS expression to evaluate. Both
|
|
// must be banned entirely, not just capitalized component tags.
|
|
if (/<\/?[A-Za-z]/.test(stripped)) {
|
|
failures.push({
|
|
file,
|
|
message: `line ${i + 1}: raw HTML/JSX tags are not allowed; entry bodies are plain Markdown (put literal tags in backticks)`,
|
|
})
|
|
}
|
|
if (/[{}]/.test(stripped)) {
|
|
failures.push({
|
|
file,
|
|
message: `line ${i + 1}: { } are not allowed outside code (MDX evaluates {...} as a JS expression)`,
|
|
})
|
|
}
|
|
if (/javascript:/i.test(stripped)) {
|
|
failures.push({ file, message: `line ${i + 1}: javascript: URLs are not allowed` })
|
|
}
|
|
}
|
|
if (inFence) {
|
|
failures.push({ file, message: 'unclosed fenced code block' })
|
|
}
|
|
}
|
|
|
|
function checkUrls(file: string, data: Record<string, unknown>): void {
|
|
for (const field of URL_FIELDS) {
|
|
const v = data[field]
|
|
if (v === undefined) continue
|
|
if (typeof v !== 'string' || !v.startsWith('https://')) {
|
|
failures.push({ file, message: `${field} must be an https:// URL, got: ${String(v)}` })
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateAuthors(): Set<string> {
|
|
const handles = new Set<string>()
|
|
for (const filename of listFiles(AUTHORS_DIR, ['.md', '.mdx'])) {
|
|
const file = `registry/authors/${filename}`
|
|
const parsed = parseFrontmatter(file, fs.readFileSync(path.join(AUTHORS_DIR, filename), 'utf8'))
|
|
if (!parsed) continue
|
|
const { data, body } = parsed
|
|
const expectedHandle = filename.replace(/\.mdx?$/, '')
|
|
if (data.handle !== expectedHandle) {
|
|
failures.push({
|
|
file,
|
|
message: `handle "${String(data.handle)}" must equal the filename "${expectedHandle}"`,
|
|
})
|
|
}
|
|
if (typeof data.name !== 'string' || data.name.length === 0) {
|
|
failures.push({ file, message: 'name is required' })
|
|
}
|
|
if (!AUTHOR_KINDS.includes(data.kind as (typeof AUTHOR_KINDS)[number])) {
|
|
failures.push({
|
|
file,
|
|
message: `kind must be one of ${AUTHOR_KINDS.join(', ')}, got: ${String(data.kind)}`,
|
|
})
|
|
}
|
|
if (typeof data.handle === 'string') {
|
|
if (handles.has(data.handle)) {
|
|
failures.push({ file, message: `duplicate author handle "${data.handle}"` })
|
|
}
|
|
handles.add(data.handle)
|
|
}
|
|
checkUrls(file, data)
|
|
checkBodySafety(file, body)
|
|
}
|
|
return handles
|
|
}
|
|
|
|
function validateEntries(authorHandles: Set<string>): number {
|
|
const slugs = new Set<string>()
|
|
const files = listFiles(ENTRIES_DIR, ['.md', '.mdx'])
|
|
for (const filename of files) {
|
|
const file = `registry/entries/${filename}`
|
|
const parsed = parseFrontmatter(file, fs.readFileSync(path.join(ENTRIES_DIR, filename), 'utf8'))
|
|
if (!parsed) continue
|
|
const { data, body } = parsed
|
|
|
|
for (const field of ENTRY_REQUIRED) {
|
|
if (data[field] === undefined || data[field] === null || data[field] === '') {
|
|
failures.push({ file, message: `required field "${field}" is missing` })
|
|
}
|
|
}
|
|
|
|
const expectedSlug = filename.replace(/\.mdx?$/, '')
|
|
if (data.slug !== undefined && data.slug !== expectedSlug) {
|
|
failures.push({
|
|
file,
|
|
message: `slug "${String(data.slug)}" must equal the filename "${expectedSlug}"`,
|
|
})
|
|
}
|
|
if (typeof data.slug === 'string') {
|
|
if (slugs.has(data.slug)) {
|
|
failures.push({ file, message: `duplicate slug "${data.slug}"` })
|
|
}
|
|
slugs.add(data.slug)
|
|
}
|
|
|
|
if (data.kind !== undefined && !KINDS.includes(data.kind as (typeof KINDS)[number])) {
|
|
failures.push({ file, message: `kind must be one of ${KINDS.join(', ')}, got: ${String(data.kind)}` })
|
|
}
|
|
if (data.status !== undefined && !STATUSES.includes(data.status as (typeof STATUSES)[number])) {
|
|
failures.push({
|
|
file,
|
|
message: `status must be one of ${STATUSES.join(', ')}, got: ${String(data.status)}`,
|
|
})
|
|
}
|
|
if (data.lang !== undefined && !LANGS.includes(data.lang as (typeof LANGS)[number])) {
|
|
failures.push({ file, message: `lang must be sv or en, got: ${String(data.lang)}` })
|
|
}
|
|
if (data.personas !== undefined) {
|
|
if (!Array.isArray(data.personas) || data.personas.length === 0) {
|
|
failures.push({ file, message: 'personas must be a non-empty list' })
|
|
} else {
|
|
for (const p of data.personas) {
|
|
if (!PERSONAS.includes(p as (typeof PERSONAS)[number])) {
|
|
failures.push({
|
|
file,
|
|
message: `unknown persona "${String(p)}"; allowed: ${PERSONAS.join(', ')}`,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const field of ['publishedAt', 'updatedAt'] as const) {
|
|
if (data[field] !== undefined && !isIsoDate(data[field])) {
|
|
failures.push({ file, message: `${field} must be a YYYY-MM-DD date, got: ${String(data[field])}` })
|
|
}
|
|
}
|
|
if (isIsoDate(data.publishedAt) && isIsoDate(data.updatedAt) && data.updatedAt < data.publishedAt) {
|
|
failures.push({ file, message: `updatedAt ${data.updatedAt} is before publishedAt ${data.publishedAt}` })
|
|
}
|
|
|
|
if (typeof data.description === 'string' && data.description.length > 500) {
|
|
failures.push({
|
|
file,
|
|
message: `description is ${data.description.length} chars; keep it under 500 (it is a card subtitle, the body is for detail)`,
|
|
})
|
|
}
|
|
|
|
if (!INSTALL_FIELDS.some((f) => typeof data[f] === 'string' && (data[f] as string).length > 0)) {
|
|
failures.push({
|
|
file,
|
|
message: `at least one of ${INSTALL_FIELDS.join(', ')} is required so readers can actually get the thing`,
|
|
})
|
|
}
|
|
|
|
if (typeof data.author === 'string' && !authorHandles.has(data.author)) {
|
|
failures.push({
|
|
file,
|
|
message: `author "${data.author}" has no profile in registry/authors/; add ${data.author}.mdx in the same PR`,
|
|
})
|
|
}
|
|
|
|
checkUrls(file, data)
|
|
checkBodySafety(file, body)
|
|
|
|
if (body.trim().length === 0) {
|
|
failures.push({ file, message: 'entry body is empty; describe what it does and how to use it' })
|
|
}
|
|
}
|
|
return files.length
|
|
}
|
|
|
|
const authorHandles = validateAuthors()
|
|
const entryCount = validateEntries(authorHandles)
|
|
|
|
if (process.argv.includes('--json')) {
|
|
console.log(JSON.stringify({ entries: entryCount, authors: authorHandles.size, failures }, null, 2))
|
|
} else {
|
|
for (const f of failures) {
|
|
console.error(`FAIL ${f.file}: ${f.message}`)
|
|
}
|
|
console.log(`${entryCount} entries, ${authorHandles.size} authors, ${failures.length} failures`)
|
|
}
|
|
process.exit(failures.length > 0 ? 1 : 0)
|