diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a976cfe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Lint & format (Biome) + run: pnpm run check + + - name: Type check + run: pnpm run typecheck + + - name: Schema documentation completeness + run: pnpm run check:docs diff --git a/.gitignore b/.gitignore index 2885597..512e929 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ schemas/ wrangler.jsonc site/resources/ site/data-types/ +site/public/schema/ site/.vitepress/dist/ site/.vitepress/cache/ site/.vitepress/sidebar.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a80625..6c1aaf8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,20 @@ That's it. All contributions are reviewed before merging. - Follow existing naming conventions and patterns in the TypeScript types. - If proposing a new resource, look at how existing resources are structured and follow the same approach. +## Code Quality + +This project uses [Biome](https://biomejs.dev/) for linting, formatting, and import sorting. + +Before opening a PR, run: + +```bash +pnpm run check # lint + format + import check (what CI runs) +pnpm run check:fix # auto-fix all issues +pnpm run typecheck # TypeScript type checking +``` + +CI runs these checks automatically on every pull request. + ## Questions or Ideas? If you want to discuss something before opening a PR, reach out at **contact@bind-standard.org**. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..d15e525 --- /dev/null +++ b/biome.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.0/schema.json", + "files": { + "includes": [ + "src/**", + "scripts/**/*.ts", + "site/.vitepress/config.mts", + "site/.vitepress/theme/**", + "package.json", + "tsconfig.json", + "biome.json" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "useImportType": "error", + "useExportType": "error", + "noUnusedTemplateLiteral": "off" + }, + "suspicious": { + "noExplicitAny": "warn" + }, + "correctness": { + "noUnusedImports": "warn" + } + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "overrides": [ + { + "includes": ["scripts/**/*.ts"], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } + }, + { + "includes": ["**/*.vue"], + "linter": { + "rules": { + "correctness": { + "noUnusedVariables": "off", + "noUnusedFunctionParameters": "off", + "noUnusedImports": "off" + } + } + } + } + ] +} diff --git a/package.json b/package.json index 6609dca..078bd76 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,18 @@ "docs:dev": "pnpm run gen && vitepress dev site", "docs:build": "pnpm run gen && vitepress build site", "docs:preview": "vitepress preview site", - "clean": "rm -rf dist schemas/resources schemas/supporting schemas/index.json site/resources site/data-types site/.vitepress/dist site/.vitepress/cache", + "clean": "rm -rf dist schemas/resources schemas/supporting schemas/index.json site/resources site/data-types site/public/schema site/.vitepress/dist site/.vitepress/cache", "wrangler:config": "ts-node scripts/generate-wrangler-config.ts", "deploy": "pnpm run wrangler:config && pnpm exec wrangler deploy", - "deploy:delete": "pnpm exec wrangler delete --force" + "deploy:delete": "pnpm exec wrangler delete --force", + "lint": "biome lint .", + "lint:fix": "biome lint --fix .", + "format": "biome format --fix .", + "format:check": "biome format .", + "check": "biome check .", + "check:fix": "biome check --fix .", + "typecheck": "tsc --noEmit", + "check:docs": "pnpm run gen:schema && ts-node scripts/check-schema-docs.ts" }, "keywords": [ "bind", @@ -30,6 +38,7 @@ "type": "commonjs", "packageManager": "pnpm@10.24.0", "devDependencies": { + "@biomejs/biome": "^2.4.0", "@types/d3-drag": "^3.0.7", "@types/d3-force": "^3.0.10", "@types/d3-selection": "^3.0.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a66095e..183980a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@biomejs/biome': + specifier: ^2.4.0 + version: 2.4.0 '@types/d3-drag': specifier: ^3.0.7 version: 3.0.7 @@ -146,6 +149,59 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@biomejs/biome@2.4.0': + resolution: {integrity: sha512-iluT61cORUDIC5i/y42ljyQraCemmmcgbMLLCnYO+yh+2hjTmcMFcwY8G0zTzWCsPb3t3AyKc+0t/VuhPZULUg==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.0': + resolution: {integrity: sha512-L+YpOtPSuU0etomfvFTPWRsa7+8ejaJL3yaROEoT/96HDJbR6OsvZQk0C8JUYou+XFdP+JcGxqZknkp4n934RA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.0': + resolution: {integrity: sha512-Aq+S7ffpb5ynTyLgtnEjG+W6xuTd2F7FdC7J6ShpvRhZwJhjzwITGF9vrqoOnw0sv1XWkt2Q1Rpg+hleg/Xg7Q==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.0': + resolution: {integrity: sha512-1rhDUq8sf7xX3tg7vbnU3WVfanKCKi40OXc4VleBMzRStmQHdeBY46aFP6VdwEomcVjyNiu+Zcr3LZtAdrZrjQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.4.0': + resolution: {integrity: sha512-u2p54IhvNAWB+h7+rxCZe3reNfQYFK+ppDw+q0yegrGclFYnDPZAntv/PqgUacpC3uxTeuWFgWW7RFe3lHuxOA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.4.0': + resolution: {integrity: sha512-Omo0xhl63z47X+CrE5viEWKJhejJyndl577VoXg763U/aoATrK3r5+8DPh02GokWPeODX1Hek00OtjjooGan9w==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.4.0': + resolution: {integrity: sha512-WVFOhsnzhrbMGOSIcB9yFdRV2oG2KkRRhIZiunI9gJqSU3ax9ErdnTxRfJUxZUI9NbzVxC60OCXNcu+mXfF/Tw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.4.0': + resolution: {integrity: sha512-aqRwW0LJLV1v1NzyLvLWQhdLmDSAV1vUh+OBdYJaa8f28XBn5BZavo+WTfqgEzALZxlNfBmu6NGO6Al3MbCULw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.0': + resolution: {integrity: sha512-g47s+V+OqsGxbSZN3lpav6WYOk0PIc3aCBAq+p6dwSynL3K5MA6Cg6nkzDOlu28GEHwbakW+BllzHCJCxnfK5Q==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@cloudflare/kv-asset-handler@0.4.2': resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} @@ -1573,6 +1629,41 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@biomejs/biome@2.4.0': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.0 + '@biomejs/cli-darwin-x64': 2.4.0 + '@biomejs/cli-linux-arm64': 2.4.0 + '@biomejs/cli-linux-arm64-musl': 2.4.0 + '@biomejs/cli-linux-x64': 2.4.0 + '@biomejs/cli-linux-x64-musl': 2.4.0 + '@biomejs/cli-win32-arm64': 2.4.0 + '@biomejs/cli-win32-x64': 2.4.0 + + '@biomejs/cli-darwin-arm64@2.4.0': + optional: true + + '@biomejs/cli-darwin-x64@2.4.0': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.0': + optional: true + + '@biomejs/cli-linux-arm64@2.4.0': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.0': + optional: true + + '@biomejs/cli-linux-x64@2.4.0': + optional: true + + '@biomejs/cli-win32-arm64@2.4.0': + optional: true + + '@biomejs/cli-win32-x64@2.4.0': + optional: true + '@cloudflare/kv-asset-handler@0.4.2': {} '@cloudflare/unenv-preset@2.12.1(unenv@2.0.0-rc.24)(workerd@1.20260212.0)': diff --git a/scripts/check-schema-docs.ts b/scripts/check-schema-docs.ts new file mode 100644 index 0000000..edd1e9f --- /dev/null +++ b/scripts/check-schema-docs.ts @@ -0,0 +1,75 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +const schemasDir = join(__dirname, "..", "schemas"); +const schemaDirs = [join(schemasDir, "resources"), join(schemasDir, "supporting")]; + +interface SchemaProperty { + type?: string; + description?: string; + $ref?: string; + const?: unknown; + properties?: Record; + items?: SchemaProperty; +} + +interface SchemaDefinition { + type?: string; + description?: string; + properties?: Record; +} + +interface Schema { + $ref?: string; + definitions?: Record; +} + +let errorCount = 0; + +function reportError(typeName: string, message: string): void { + console.error(` ✗ ${typeName}: ${message}`); + errorCount++; +} + +for (const dir of schemaDirs) { + const files = readdirSync(dir).filter((f) => f.endsWith(".schema.json")); + + for (const file of files) { + const typeName = basename(file, ".schema.json"); + const schema: Schema = JSON.parse(readFileSync(join(dir, file), "utf-8")); + + // Resolve the root definition + const rootRefName = schema.$ref ? schema.$ref.replace("#/definitions/", "") : typeName; + const rootDef = schema.definitions?.[rootRefName]; + + if (!rootDef) { + reportError(typeName, "root definition not found"); + continue; + } + + // Check root type has a description + if (!rootDef.description) { + reportError(typeName, "missing root description (add JSDoc to the type)"); + } + + // Check each property has a description + if (rootDef.properties) { + for (const [propName, prop] of Object.entries(rootDef.properties)) { + // Skip const fields (e.g. resourceType discriminators) — they're self-documenting + if (prop.const !== undefined) continue; + + if (!prop.description) { + reportError(typeName, `property "${propName}" is missing a description`); + } + } + } + } +} + +if (errorCount > 0) { + console.error(`\n${errorCount} documentation issue(s) found.`); + console.error("Add JSDoc comments to the TypeScript source types to fix these."); + process.exit(1); +} else { + console.log("All schema types and properties are documented."); +} diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 58e6c27..7d8eb9e 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -1,5 +1,5 @@ -import { readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs"; -import { join, basename } from "path"; +import { copyFileSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; // --------------------------------------------------------------------------- // Config @@ -116,7 +116,7 @@ function firstSentence(desc: string | undefined): string { function walkProperties( props: Record, requiredFields: string[], - definitions: Record + definitions: Record, ): TreeNode[] { const nodes: TreeNode[] = []; @@ -203,7 +203,7 @@ function walkProperties( function generateDetailPage( typeName: string, schema: Schema, - fromSection: "resources" | "data-types" + _fromSection: "resources" | "data-types", ): string { const rootRef = schema.$ref ? resolveRefName(schema.$ref) : typeName; const definitions = schema.definitions || {}; @@ -242,7 +242,7 @@ function generateDetailPage( lines.push(``); lines.push(""); lines.push( - `` + ``, ); lines.push(""); } @@ -258,15 +258,9 @@ function generateDetailPage( } // JSON Schema link - const isResource = resourceCategories.flatMap((c) => c.items).includes(typeName); - const schemaRelPath = isResource - ? `resources/${typeName}.schema.json` - : `supporting/${typeName}.schema.json`; lines.push("## JSON Schema"); lines.push(""); - lines.push( - `Full JSON Schema: [\`${schemaRelPath}\`](https://bind-standard.org/schema/${typeName})` - ); + lines.push(`Full JSON Schema: [\`${typeName}.schema.json\`](/schema/${typeName}.schema.json)`); lines.push(""); return lines.join("\n"); @@ -286,7 +280,7 @@ function generateResourceIndex(resourceSchemas: Map): string { lines.push("# Resources"); lines.push(""); lines.push( - "BIND resources are the core building blocks of the standard. Each resource represents a distinct concept in insurance." + "BIND resources are the core building blocks of the standard. Each resource represents a distinct concept in insurance.", ); lines.push(""); @@ -321,16 +315,14 @@ function generateDataTypesIndex(supportingSchemas: Map): string lines.push("# Data Types"); lines.push(""); lines.push( - "Data types are reusable structures shared across BIND resources. They represent common concepts like monetary values, coded references, addresses, and time periods." + "Data types are reusable structures shared across BIND resources. They represent common concepts like monetary values, coded references, addresses, and time periods.", ); lines.push(""); lines.push("| Data Type | Description |"); lines.push("|-----------|-------------|"); - const sorted = [...supportingSchemas.entries()].sort((a, b) => - a[0].localeCompare(b[0]) - ); + const sorted = [...supportingSchemas.entries()].sort((a, b) => a[0].localeCompare(b[0])); for (const [name, schema] of sorted) { const rootRef = schema.$ref ? resolveRefName(schema.$ref) : name; @@ -354,12 +346,8 @@ interface SidebarItem { items?: SidebarItem[]; } -function generateSidebarConfig( - supportingNames: string[] -): Record { - const resourceSidebar: SidebarItem[] = [ - { text: "Resource Index", link: "/resources/" }, - ]; +function generateSidebarConfig(supportingNames: string[]): Record { + const resourceSidebar: SidebarItem[] = [{ text: "Resource Index", link: "/resources/" }]; for (const cat of resourceCategories) { resourceSidebar.push({ @@ -371,9 +359,7 @@ function generateSidebarConfig( }); } - const dataTypeSidebar: SidebarItem[] = [ - { text: "Data Types Index", link: "/data-types/" }, - ]; + const dataTypeSidebar: SidebarItem[] = [{ text: "Data Types Index", link: "/data-types/" }]; const sorted = [...supportingNames].sort(); for (const name of sorted) { @@ -447,10 +433,28 @@ function main() { const sidebar = generateSidebarConfig([...supportingTypeNames]); writeFileSync( join(siteDir, ".vitepress", "sidebar.json"), - JSON.stringify(sidebar, null, 2) + "\n" + `${JSON.stringify(sidebar, null, 2)}\n`, ); console.log(` ✓ .vitepress/sidebar.json`); + // Copy schema files into public dir so they're served as static assets + const publicSchemaDir = join(siteDir, "public", "schema"); + mkdirSync(publicSchemaDir, { recursive: true }); + + for (const [name] of resourceSchemas) { + copyFileSync( + join(resourceSchemasDir, `${name}.schema.json`), + join(publicSchemaDir, `${name}.schema.json`), + ); + } + for (const [name] of supportingSchemas) { + copyFileSync( + join(supportingSchemasDir, `${name}.schema.json`), + join(publicSchemaDir, `${name}.schema.json`), + ); + } + console.log(` ✓ public/schema/ (${resourceSchemas.size + supportingSchemas.size} files)`); + console.log(`\nGenerated ${successCount} documentation pages.`); } diff --git a/scripts/generate-schemas.d.ts b/scripts/generate-schemas.d.ts index bfc47bf..c7f83ea 100644 --- a/scripts/generate-schemas.d.ts +++ b/scripts/generate-schemas.d.ts @@ -1,2 +1,2 @@ export {}; -//# sourceMappingURL=generate-schemas.d.ts.map \ No newline at end of file +//# sourceMappingURL=generate-schemas.d.ts.map diff --git a/scripts/generate-schemas.ts b/scripts/generate-schemas.ts index 407fcac..6b92dd9 100644 --- a/scripts/generate-schemas.ts +++ b/scripts/generate-schemas.ts @@ -1,6 +1,6 @@ -import { createGenerator, Config } from "ts-json-schema-generator"; -import { writeFileSync, mkdirSync, readdirSync, readFileSync } from "fs"; -import { join } from "path"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { type Config, createGenerator } from "ts-json-schema-generator"; // All BIND resource types (top-level resources with resourceType discriminator) const resourceTypes = [ @@ -100,14 +100,14 @@ for (const typeName of allTypes) { const schema = createGenerator(config).createSchema(typeName); // Add BIND-specific metadata - (schema as any)["$id"] = `https://bind-standard.org/schema/${typeName}`; - (schema as any)["title"] = typeName; + (schema as any).$id = `https://bind-standard.org/schema/${typeName}`; + (schema as any).title = typeName; const isResource = resourceTypes.includes(typeName); const outDir = isResource ? resourceSchemasDir : supportingSchemasDir; const outPath = join(outDir, `${typeName}.schema.json`); - writeFileSync(outPath, JSON.stringify(schema, null, 2) + "\n"); + writeFileSync(outPath, `${JSON.stringify(schema, null, 2)}\n`); console.log(` ✓ ${typeName}`); successCount++; } catch (err: any) { @@ -116,16 +116,13 @@ for (const typeName of allTypes) { } } -console.log( - `\nGenerated ${successCount} schemas (${errorCount} errors) → schemas/` -); +console.log(`\nGenerated ${successCount} schemas (${errorCount} errors) → schemas/`); // Generate a master index of all schemas const index = { $schema: "http://json-schema.org/draft-07/schema#", title: "BIND Standard Schema Index", - description: - "Index of all BIND (Business Insurance Normalized Data) standard schemas.", + description: "Index of all BIND (Business Insurance Normalized Data) standard schemas.", resources: resourceTypes.map((t) => ({ name: t, schema: `resources/${t}.schema.json`, @@ -138,8 +135,5 @@ const index = { })), }; -writeFileSync( - join(schemasDir, "index.json"), - JSON.stringify(index, null, 2) + "\n" -); +writeFileSync(join(schemasDir, "index.json"), `${JSON.stringify(index, null, 2)}\n`); console.log("Generated schema index → schemas/index.json"); diff --git a/scripts/generate-wrangler-config.ts b/scripts/generate-wrangler-config.ts index 2a852f7..17edbce 100644 --- a/scripts/generate-wrangler-config.ts +++ b/scripts/generate-wrangler-config.ts @@ -1,5 +1,5 @@ -import { writeFileSync } from "fs"; -import { join } from "path"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; type DeployContext = "production" | "pr-preview" | "branch-preview"; @@ -53,9 +53,7 @@ function buildConfig(context: DeployContext): WranglerConfig { case "pr-preview": { const prNumber = process.env.PR_NUMBER; if (!prNumber) { - throw new Error( - "PR_NUMBER environment variable is required for pr-preview context" - ); + throw new Error("PR_NUMBER environment variable is required for pr-preview context"); } return { ...base, @@ -72,9 +70,7 @@ function buildConfig(context: DeployContext): WranglerConfig { case "branch-preview": { const branchName = process.env.BRANCH_NAME; if (!branchName) { - throw new Error( - "BRANCH_NAME environment variable is required for branch-preview context" - ); + throw new Error("BRANCH_NAME environment variable is required for branch-preview context"); } const sanitized = sanitizeBranchName(branchName); return { diff --git a/site/.vitepress/config.mts b/site/.vitepress/config.mts index f21f491..0e33c7e 100644 --- a/site/.vitepress/config.mts +++ b/site/.vitepress/config.mts @@ -1,13 +1,11 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitepress"; -import { readFileSync } from "fs"; -import { fileURLToPath } from "url"; -import { dirname, resolve } from "path"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const sidebar = JSON.parse( - readFileSync(resolve(__dirname, "sidebar.json"), "utf-8") -); +const sidebar = JSON.parse(readFileSync(resolve(__dirname, "sidebar.json"), "utf-8")); export default defineConfig({ title: "BIND Standard", @@ -29,8 +27,6 @@ export default defineConfig({ ], sidebar, search: { provider: "local" }, - socialLinks: [ - { icon: "github", link: "https://github.com/bind-standard/bind" }, - ], + socialLinks: [{ icon: "github", link: "https://github.com/bind-standard/bind" }], }, }); diff --git a/site/.vitepress/theme/ConceptExplorer.vue b/site/.vitepress/theme/ConceptExplorer.vue index 3474680..8252ff8 100644 --- a/site/.vitepress/theme/ConceptExplorer.vue +++ b/site/.vitepress/theme/ConceptExplorer.vue @@ -1,12 +1,10 @@