Files
accounted/scripts/validate-registry.ts
T
Jakob Wennberg 3d1ed15b6d feat(registry): move community registry source of truth into the public repo (#1458)
* 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>
2026-08-08 10:54:09 +02:00

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)