diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..f821103 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,27 @@ +name: Validate SPP Data + +on: + push: + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: | + cd scripts + bun install + + - name: Run validation + run: | + cd scripts + bun run validate diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..94a537f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": false, + "tabWidth": 2, + "printWidth": 80, + "useTabs": false, + "singleQuote": true, + "trailingComma": "es5" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..c83e263 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["esbenp.prettier-vscode"] +} diff --git a/README.md b/README.md index c0aabd2..6570ce3 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,14 @@ The goal of this repo is to provide all relevant data for ENS DAO Service Provid ## Structure -- Each SPP season has its own folder in the root of the repo, e.g. `spp-2` +- Each SPP season has its own folder in the root of the repo, e.g. `spp-2`. - Each provider has its own folder within the SPP season with the following structure: - - `proposal.md` - the initial proposal for the season that got approved by the ENS DAO, including a bunch of stuff in the frontmatter, e.g. `name`, `description`, `logo`, `website`, `twitter`, `github` - - `assets/` - any relevant files, like images, for the proposal/updates, e.g. `logo.svg` - - `updates/` - the quarterly updates for the provider - - `1.md` - the first update for the provider, e.g. `Quarterly Report Q1 2025` - - `2.md` - the second update for the provider, e.g. `Quarterly Report Q2 2025`, etc. + - `proposal.md` - the initial proposal for the season that got approved by the ENS DAO. This file must include basic information about the provider in the frontmatter including `name`, `description`, `logo`, `website`, `twitter`, `github`. + - `assets/` - any relevant files, like images, for the proposal/updates, e.g. `logo.svg`. + - `updates/` - the quarterly updates for the provider. + - `1.md` - the first update for the provider in the given season. - ... ## Guidelines -ENS DAO MetaGov stewards are admins of this repo, and responsible for merging PRs from service providers. They should only merge PRs once the formatting is correct. +ENS DAO MetaGov stewards are admins of this repo, and responsible for merging PRs from service providers. They should only merge PRs once the formatting is correct, which is partially validated by the CI. diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/scripts/bun.lock b/scripts/bun.lock new file mode 100644 index 0000000..4e843d2 --- /dev/null +++ b/scripts/bun.lock @@ -0,0 +1,58 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "scripts", + "dependencies": { + "gray-matter": "^4.0.3", + "marked": "^16.2.0", + "zod": "^4.1.3", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="], + + "@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], + + "@types/react": ["@types/react@19.1.11", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ=="], + + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + + "js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], + + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + + "marked": ["marked@16.2.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-LbbTuye+0dWRz2TS9KJ7wsnD4KAtpj0MVkWc90XvBa6AslXsT0hTBVH5k32pcSyHH1fst9XEFJunXHktVy0zlg=="], + + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + + "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + + "zod": ["zod@4.1.3", "", {}, "sha512-1neef4bMce1hNTrxvHVKxWjKfGDn0oAli3Wy1Uwb7TRO1+wEwoZUZNP1NXIEESybOBiFnBOhI6a4m6tCLE8dog=="], + } +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..f4ee09a --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "type": "module", + "scripts": { + "validate": "bun run src/index.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "gray-matter": "^4.0.3", + "marked": "^16.2.0", + "zod": "^4.1.3" + } +} diff --git a/scripts/src/error.ts b/scripts/src/error.ts new file mode 100644 index 0000000..20cfdf0 --- /dev/null +++ b/scripts/src/error.ts @@ -0,0 +1,27 @@ +import path from 'node:path' + +type GitHubErrorOptions = { + filename: string + title: string + message: string +} + +export class GitHubError extends Error { + public readonly filename: string + public readonly title: string + public readonly annotation: string + + constructor({ filename, title, message }: GitHubErrorOptions) { + // Use the message as the regular error message + super(message) + + // Convert the filename to a relative path + const rootPath = path.join(import.meta.dirname, '..', '..') + const relativeFilename = path.relative(rootPath, filename) + + // Store the GitHub annotation separately + this.filename = relativeFilename + this.title = title + this.annotation = `::error file=${relativeFilename},line=1,title=${title}::${message}` + } +} diff --git a/scripts/src/index.ts b/scripts/src/index.ts new file mode 100644 index 0000000..88eefb6 --- /dev/null +++ b/scripts/src/index.ts @@ -0,0 +1,100 @@ +import { readdir } from 'node:fs/promises' +import matter from 'gray-matter' +import path from 'node:path' + +import { GitHubError } from './error' +import { ProposalFrontmatter } from './schema' +import { validateProposalHeadings, validateUpdateHeadings } from './validate' + +const rootPath = path.join(import.meta.dirname, '..', '..') +const root = await readdir(rootPath) +const sppSeasons = root.filter((entry) => entry.startsWith('spp-')) + +try { + await validate() +} catch (error) { + if (error instanceof GitHubError) { + console.log(error.annotation) + process.exit(1) + } + + throw error +} + +async function validate() { + for (const sppSeason of sppSeasons) { + const seasonPath = path.join(rootPath, sppSeason) + const providers = await readdir(seasonPath) + + for (const provider of providers) { + const providerPath = path.join(seasonPath, provider) + + // Read the proposal.md file + const proposalPath = path.join(providerPath, 'proposal.md') + const { data: proposalFrontmatter, content: proposalMarkdown } = matter( + await Bun.file(proposalPath).text() + ) + + // Validate the frontmatter + const { data: validatedFrontmatter, error: frontmatterError } = + ProposalFrontmatter.safeParse(proposalFrontmatter) + + if (frontmatterError) { + throw new GitHubError({ + filename: proposalPath, + title: 'Invalid frontmatter', + message: `The frontmatter is invalid: ${frontmatterError.issues + .map((issue) => issue.message) + .join(', ')}`, + }) + } + + // Check if the logo (as a relative path) exists + const logoPath = path.join(providerPath, validatedFrontmatter.logo) + const logoExists = await Bun.file(logoPath).exists() + if (!logoExists) { + throw new GitHubError({ + filename: proposalPath, + title: 'Logo does not exist', + message: `\`${validatedFrontmatter.logo}\` does not exist`, + }) + } + + validateProposalHeadings(proposalMarkdown, proposalPath) + + const updatesPath = path.join(providerPath, 'updates') + const updates = await readdir(updatesPath) + const updateFiles = updates.filter((file) => file.endsWith('.md')) + + // Make sure there's nothing else besides {number}.md files + if (updateFiles.length !== updates.length) { + throw new GitHubError({ + filename: providerPath, + title: 'Invalid update files', + message: `There should only be numbered .md files in the updates folder`, + }) + } + + // Check that updates are numbered sequentially + for (let i = 0; i < updateFiles.length; i++) { + const update = updateFiles[i]! + const updateNumber = parseInt(update.split('.')[0]!) + if (updateNumber !== i + 1) { + throw new GitHubError({ + filename: updatesPath, + title: 'Updates are not numbered sequentially', + message: `The update files are not numbered sequentially`, + }) + } + } + + for (const updateFile of updateFiles) { + const updatePath = path.join(providerPath, 'updates', updateFile) + const updateContent = await Bun.file(updatePath).text() + const { content: updateMarkdown } = matter(updateContent) + + validateUpdateHeadings(updateMarkdown, updatePath) + } + } + } +} diff --git a/scripts/src/schema.ts b/scripts/src/schema.ts new file mode 100644 index 0000000..5d47cee --- /dev/null +++ b/scripts/src/schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +export const ProposalFrontmatter = z.object({ + name: z.string('Name is required'), + description: z.string('Description is required'), + logo: z + .string('Logo is required as a relative path') + .refine((str) => str.startsWith('./')), + website: z.url('Website is required'), + twitter: z.url('Twitter (full URL, not just username) is required'), + github: z.url('GitHub (full URL, not just username) is required'), +}) diff --git a/scripts/src/validate.ts b/scripts/src/validate.ts new file mode 100644 index 0000000..93ee6d5 --- /dev/null +++ b/scripts/src/validate.ts @@ -0,0 +1,79 @@ +import { marked, type Tokens } from 'marked' + +import { GitHubError } from './error' + +const requiredProposalH2s = [ + 'Applicant Information', + 'Eligibility Confirmation', + 'Open Source Commitment', + 'Scope of Work & Budget', + 'Past Achievements & Additional Information', + 'Video Introduction', + 'Conflict Of Interest Statment', +] + +export function validateProposalHeadings(content: string, filename: string) { + const { h1s, h2s } = extractHeadings(content) + + if (h1s.length !== 1) { + throw new GitHubError({ + filename, + title: 'Only one H1 is allowed', + message: 'Please add only one H1 to the proposal.md file.', + }) + } + + const missingH2s = requiredProposalH2s.filter( + (h2) => !h2s.some((h) => h.text.includes(h2)) + ) + + if (missingH2s.length > 0) { + throw new GitHubError({ + filename, + title: 'Missing required headings', + message: `This proposal is missing the following headings: ${missingH2s.join( + ', ' + )}`, + }) + } +} + +const requiredUpdateH2s = ['Summary', 'KPIs'] + +export function validateUpdateHeadings(content: string, filename: string) { + const { h1s, h2s } = extractHeadings(content) + + if (h1s.length !== 1) { + throw new GitHubError({ + filename, + title: 'Only one H1 is allowed', + message: 'Please add only one H1 to the update.md file.', + }) + } + + const missingH2s = requiredUpdateH2s.filter( + (h2) => !h2s.some((h) => h.text.includes(h2)) + ) + + if (missingH2s.length > 0) { + throw new GitHubError({ + filename, + title: 'Missing required headings', + message: `This update is missing the following headings: ${missingH2s.join( + ', ' + )}`, + }) + } +} + +function extractHeadings(content: string) { + const tokens = marked.lexer(content) + const headingTokens = tokens.filter( + (token) => token.type === 'heading' + ) as Tokens.Heading[] + + const h1s = headingTokens.filter((token) => token.depth === 1) + const h2s = headingTokens.filter((token) => token.depth === 2) + + return { h1s, h2s } +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..9c62f74 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}