diff --git a/eslint.config.mjs b/eslint.config.mjs index 22647924d8e..7b1c65f10d7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -429,6 +429,12 @@ const config = defineConfig([ camelcase: 'off', }, }, + { + files: ['packages/cli/**/*.{ts,tsx}'], + rules: { + 'import/no-namespace': 'off', + }, + }, ]) export default tseslint.config(config) diff --git a/package-lock.json b/package-lock.json index f4cebe20089..f3dc790159e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6612,6 +6612,10 @@ "integrity": "sha512-cxxWDeR5IE7vdL0Ca0RAAAuPVrU+Bo8CSP1WfawGo4rEP1sZTi7hwl5i7TT0jCTCR0C9IfnQBfSPAkz0oznxUw==", "license": "MIT" }, + "node_modules/@primer/cli": { + "resolved": "packages/cli", + "link": true + }, "node_modules/@primer/css": { "version": "21.5.1", "dev": true, @@ -11929,6 +11933,12 @@ "node": ">=8" } }, + "node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "license": "MIT" + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -28646,6 +28656,127 @@ "url": "https://github.com/sponsors/isaacs" } }, + "packages/cli": { + "name": "@primer/cli", + "version": "0.0.0", + "dependencies": { + "citty": "^0.2.2", + "zod": "^4.4.3" + }, + "bin": { + "cli": "bin/primer-cli.js" + }, + "devDependencies": { + "@primer/vitest-config": "^0.0.0", + "rimraf": "^6.0.1", + "rolldown": "^1.2.0", + "typescript": "^6.0.3" + } + }, + "packages/cli/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "packages/cli/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "packages/cli/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/cli/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "packages/cli/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/cli/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/cli/node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "packages/doc-gen": { "name": "@primer/doc-gen", "version": "0.0.1", @@ -28971,7 +29102,7 @@ "@storybook/addon-links": "10.5.5", "@storybook/addon-mcp": "^0.7.0", "@storybook/icons": "^2.1.0", - "@storybook/react-vite": "^10.5.5", + "@storybook/react-vite": "10.5.5", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.4.5", "@testing-library/react": "^16.3.0", @@ -29953,7 +30084,7 @@ "@primer/primitives": "10.x || 11.x", "@primer/react": "^38.26.0", "@rolldown/plugin-babel": "^0.2.3", - "@storybook/react-vite": "^10.5.5", + "@storybook/react-vite": "10.5.5", "@types/babel__core": "^7.20.5", "@types/react": "18.3.11", "@types/react-dom": "18.3.1", diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 00000000000..2306b497266 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,14 @@ +# @primer/cli + +## Features + +- Components + - Documentation + - Usage + - Accessibility + - Storybook + - API + - Hierarchy + - Precedence +- Tokens +- Patterns diff --git a/packages/cli/bin/primer-cli.js b/packages/cli/bin/primer-cli.js new file mode 100755 index 00000000000..4b7f9dde603 --- /dev/null +++ b/packages/cli/bin/primer-cli.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import {main} from '../dist/cli.js' + +main() diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000000..c018eb6bb69 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,27 @@ +{ + "name": "@primer/cli", + "version": "0.0.0", + "type": "module", + "private": true, + "bin": "./bin/primer-cli.js", + "files": [ + "bin", + "dist" + ], + "scripts": { + "clean": "rimraf dist", + "build": "rolldown -c", + "type-check": "tsc --noEmit", + "watch": "rolldown -c -w" + }, + "dependencies": { + "citty": "^0.2.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@primer/vitest-config": "^0.0.0", + "rimraf": "^6.0.1", + "rolldown": "^1.2.0", + "typescript": "^6.0.3" + } +} diff --git a/packages/cli/rolldown.config.ts b/packages/cli/rolldown.config.ts new file mode 100644 index 00000000000..ce7cfb69a8d --- /dev/null +++ b/packages/cli/rolldown.config.ts @@ -0,0 +1,30 @@ +import {defineConfig} from 'rolldown' +import packageJson from './package.json' with {type: 'json'} + +interface PackageMetadata { + readonly peerDependencies?: Record + readonly dependencies?: Record + readonly devDependencies?: Record +} + +const packageMetadata: PackageMetadata = packageJson + +const external = [ + ...Object.keys(packageMetadata.peerDependencies ?? {}), + ...Object.keys(packageMetadata.dependencies ?? {}), + ...Object.keys(packageMetadata.devDependencies ?? {}), +].map(name => { + return new RegExp(`^${name}(/.*)?`) +}) + +export default defineConfig([ + { + input: './src/cli.ts', + platform: 'node', + external, + output: { + dir: 'dist', + format: 'esm', + }, + }, +]) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 00000000000..02a5cdd769d --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,19 @@ +import {defineCommand, runMain} from 'citty' +import {command as componentSubCommand} from './commands/component' +import {command as tokenSubCommand} from './commands/token' +import packageJson from '../package.json' with {type: 'json'} + +export function main() { + const cli = defineCommand({ + meta: { + name: packageJson.name, + version: packageJson.version, + }, + subCommands: { + component: componentSubCommand, + tokens: tokenSubCommand, + }, + }) + + runMain(cli) +} diff --git a/packages/cli/src/commands/component.ts b/packages/cli/src/commands/component.ts new file mode 100644 index 00000000000..5fbc40b249d --- /dev/null +++ b/packages/cli/src/commands/component.ts @@ -0,0 +1,201 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import {defineCommand} from 'citty' +import {prettifyError, treeifyError} from 'zod/mini' +import {list, paginate, discover, parse} from '../components' +import {createTable} from '../table' + +export const command = defineCommand({ + meta: { + name: 'component', + }, + args: { + json: { + type: 'boolean', + description: 'Output the list in JSON format', + default: false, + }, + }, + subCommands: { + list: defineCommand({ + meta: { + name: 'list', + }, + async run(ctx) { + const cwd = process.cwd() + const components = await list(cwd) + + if (ctx.args.json) { + console.log(JSON.stringify({components}, null, 2)) + } else { + const table = createTable({ + columns: ['ID', 'Name', 'Status', 'A11y Reviewed', 'Import Path'], + rows: components.flatMap(component => { + const rows = [ + [component.id, component.name, component.status, `${component.a11yReviewed}`, component.importPath], + ] + + const subcomponents = component.subcomponents?.map(subcomponent => { + return ['', ` ${subcomponent.name}`, '', '', ''] + }) + + if (Array.isArray(subcomponents)) { + rows.push(...subcomponents) + } + + return rows + }), + }) + + console.log(table.toString()) + } + }, + }), + paginate: defineCommand({ + meta: { + name: 'paginate', + }, + args: { + page: { + type: 'string', + description: 'The page to retrieve', + default: '1', + }, + pageSize: { + type: 'string', + description: 'The number of components per page', + default: '20', + }, + }, + async run(ctx) { + const cwd = process.cwd() + const result = await paginate(cwd, { + page: Number(ctx.args.page), + pageSize: Number(ctx.args.pageSize), + }) + + if (ctx.args.json) { + console.log(JSON.stringify(result, null, 2)) + } else { + const table = createTable({ + columns: ['ID', 'Name', 'Status', 'A11y Reviewed', 'Import Path'], + rows: result.rows.flatMap(component => { + const rows = [ + [component.id, component.name, component.status, `${component.a11yReviewed}`, component.importPath], + ] + + const subcomponents = component.subcomponents?.map(subcomponent => { + return ['', ` ${subcomponent.name}`, '', '', ''] + }) + + if (Array.isArray(subcomponents)) { + rows.push(...subcomponents) + } + + return rows + }), + }) + + console.log(table.toString()) + } + }, + }), + validate: defineCommand({ + meta: { + name: 'validate', + }, + args: { + filepath: { + type: 'positional', + description: 'The path to the component documentation file', + required: false, + }, + }, + async run(ctx) { + if (ctx.args.filepath) { + const contents = await fs.readFile(ctx.args.filepath, 'utf-8') + const result = parse(JSON.parse(contents)) + if (result.success) { + return + } + + if (ctx.args.json) { + const output = { + filepath: ctx.args.filepath, + success: result.success, + error: treeifyError(result.error), + } + + console.log(JSON.stringify(output, null, 2)) + } else { + const table = createTable({ + columns: ['Filepath', 'Error'], + rows: [[ctx.args.filepath, prettifyError(result.error)]], + }) + + console.log(table.toString()) + } + + process.exitCode = 1 + + return + } + + const cwd = process.cwd() + const entries = await discover(cwd) + const results = entries.map(entry => { + return { + filepath: entry.filepath, + parsed: parse(entry.data), + } + }) + const valid = results.every(result => result.parsed.success) + + if (ctx.args.json) { + const output = results + .filter(result => { + return !result.parsed.success + }) + .map(result => { + const entry: {filepath: string; success: boolean; error?: unknown} = { + filepath: result.filepath, + success: result.parsed.success, + } + + if (!result.parsed.success) { + entry['error'] = treeifyError(result.parsed.error) + } + + return entry + }) + + console.log(JSON.stringify(output, null, 2)) + } else { + const rows = results + .filter(result => { + return !result.parsed.success + }) + .map(result => { + return [ + path.relative(cwd, result.filepath), + result.parsed.success ? '' : prettifyError(result.parsed.error), + ] + }) + + const table = createTable({ + columns: ['Filepath', 'Error'], + rows, + }) + + if (rows.length > 0) { + console.log(table.toString()) + } + } + + if (!valid) { + process.exitCode = 1 + } + }, + }), + }, +}) diff --git a/packages/cli/src/commands/token.ts b/packages/cli/src/commands/token.ts new file mode 100644 index 00000000000..095822a84bf --- /dev/null +++ b/packages/cli/src/commands/token.ts @@ -0,0 +1,14 @@ +import {defineCommand} from 'citty' + +export const command = defineCommand({ + meta: { + name: 'token', + }, + subCommands: { + list: defineCommand({ + meta: { + name: 'list', + }, + }), + }, +}) diff --git a/packages/cli/src/components.ts b/packages/cli/src/components.ts new file mode 100644 index 00000000000..817fc32915f --- /dev/null +++ b/packages/cli/src/components.ts @@ -0,0 +1,101 @@ +import fs from 'node:fs/promises' +import {glob} from 'glob' +import * as z from 'zod/mini' + +const StorySchema = z.object({ + id: z.string(), +}) + +const PropSchema = z.object({ + name: z.string(), + description: z.optional(z.string()), + type: z.string(), + defaultValue: z.optional(z.string()), +}) + +const SubcomponentSchema = z.object({ + name: z.string(), + props: z.array(PropSchema), +}) + +const ComponentSchema = z.object({ + id: z.string(), + name: z.string(), + status: z.enum(['alpha', 'beta', 'draft', 'stable', 'deprecated']), + a11yReviewed: z.union([z.string(), z.literal(false)]), + importPath: z.string(), + stories: z.array(StorySchema), + props: z.array(PropSchema), + subcomponents: z.optional(z.array(SubcomponentSchema)), +}) + +type Component = z.output + +async function list(directory: string): Promise> { + const entries = await discover(directory) + return entries + .flatMap(entry => { + const parsed = parse(entry.data) + if (parsed.success) { + return parsed.data + } + return [] + }) + .sort((a, b) => { + return a.name.localeCompare(b.name) + }) +} + +type PaginateOptions = { + page: number + pageSize: number +} + +type Paginate = { + rows: Array + totalCount: number +} + +async function paginate(directory: string, options: PaginateOptions): Promise> { + const rows = await list(directory) + const totalCount = rows.length + const startIndex = (options.page - 1) * options.pageSize + const endIndex = startIndex + options.pageSize + const paginatedRows = rows.slice(startIndex, endIndex) + return { + rows: paginatedRows, + totalCount, + } +} + +async function find(): Promise { + throw new Error('unimplemented') +} + +async function get(): Promise { + throw new Error('unimplemented') +} + +function parse(data: unknown): z.util.SafeParseResult { + return ComponentSchema.safeParse(data) +} + +async function discover(directory: string): Promise> { + const filepaths = await glob(['**/*.docs.json'], { + cwd: directory, + absolute: true, + ignore: ['**/node_modules/**', '**/dist/**'], + }) + return Promise.all( + filepaths.map(async filepath => { + const data = await fs.readFile(filepath, 'utf-8') + return { + filepath, + data: JSON.parse(data), + } + }), + ) +} + +export {list, paginate, find, get, parse, discover} +export type {Component} diff --git a/packages/cli/src/table.test.ts b/packages/cli/src/table.test.ts new file mode 100644 index 00000000000..933c6f3dcce --- /dev/null +++ b/packages/cli/src/table.test.ts @@ -0,0 +1,78 @@ +import {describe, expect, test} from 'vitest' +import {createTable} from './table' + +describe('createTable', () => { + test('formats columns and rows as a padded markdown table', () => { + const table = createTable({ + columns: ['Name', 'Description'], + rows: [ + ['Button', 'Triggers action'], + ['Link', 'Navigates'], + ], + terminalWidth: 100, + }) + + expect(table.toString()).toBe( + [ + '| Name | Description |', + '| ------ | --------------- |', + '| Button | Triggers action |', + '| Link | Navigates |', + ].join('\n'), + ) + }) + + test('truncates the widest column to fit the terminal', () => { + const table = createTable({ + columns: ['Name', 'Description'], + rows: [['Button', 'Triggers action']], + terminalWidth: 24, + }) + + expect(table.toString()).toBe( + ['| Name | Description |', '| ------ | ----------- |', '| Button | Triggers a… |'].join('\n'), + ) + }) + + test('renders missing row values as empty cells', () => { + const table = createTable({ + columns: ['Name', 'Status'], + rows: [['Button']], + terminalWidth: 100, + }) + + expect(table.toString()).toBe(['| Name | Status |', '| ------ | ------ |', '| Button | |'].join('\n')) + }) + + test('normalizes newlines in cells to commas', () => { + const table = createTable({ + columns: ['Name', 'Description'], + rows: [ + ['Button\nIconButton', 'Triggers an action'], + ['Link', 'Navigates\r\n somewhere'], + ], + terminalWidth: 100, + }) + + expect(table.toString()).toBe( + [ + '| Name | Description |', + '| ------------------ | -------------------- |', + '| Button, IconButton | Triggers an action |', + '| Link | Navigates, somewhere |', + ].join('\n'), + ) + }) + + test('truncates normalized multiline cells', () => { + const table = createTable({ + columns: ['Name', 'Description'], + rows: [['Button', 'Triggers action\nNavigates elsewhere']], + terminalWidth: 24, + }) + + expect(table.toString()).toBe( + ['| Name | Description |', '| ------ | ----------- |', '| Button | Triggers a… |'].join('\n'), + ) + }) +}) diff --git a/packages/cli/src/table.ts b/packages/cli/src/table.ts new file mode 100644 index 00000000000..c1a4ac163f7 --- /dev/null +++ b/packages/cli/src/table.ts @@ -0,0 +1,82 @@ +interface CreateTableOptions { + columns: readonly string[] + rows: readonly (readonly string[])[] + terminalWidth?: number +} + +const ELLIPSIS = '…' + +function normalizeCell(value: string) { + return value.replace(/[^\S\r\n]*(?:\r\n|\r|\n)[^\S\r\n]*/g, ', ') +} + +function getCharacterCount(value: string) { + return Array.from(value).length +} + +function truncate(value: string, width: number) { + const characters = Array.from(value) + + if (characters.length <= width) { + return value + } + + return `${characters.slice(0, width - 1).join('')}${ELLIPSIS}` +} + +function formatCell(value: string, width: number) { + const content = truncate(value, width) + return ` ${content}${' '.repeat(width - getCharacterCount(content))} ` +} + +function getTableWidth(columnWidths: readonly number[]) { + return columnWidths.reduce((width, columnWidth) => width + columnWidth + 3, 1) +} + +function fitColumnWidths(columnWidths: readonly number[], terminalWidth: number | undefined) { + const widths = [...columnWidths] + + if (terminalWidth === undefined) { + return widths + } + + while (getTableWidth(widths) > terminalWidth) { + const longestColumnWidth = Math.max(...widths) + const longestColumnIndex = widths.indexOf(longestColumnWidth) + + if (longestColumnWidth <= 1) { + break + } + + widths[longestColumnIndex] -= 1 + } + + return widths +} + +export function createTable({columns, rows, terminalWidth}: CreateTableOptions) { + return { + toString() { + if (columns.length === 0) { + return '' + } + + const columnWidths = columns.map((column, columnIndex) => { + return Math.max( + getCharacterCount(normalizeCell(column)), + ...rows.map(row => getCharacterCount(normalizeCell(row[columnIndex] ?? ''))), + ) + }) + const fittedColumnWidths = fitColumnWidths(columnWidths, terminalWidth ?? process.stdout.columns) + const formatRow = (row: readonly string[]) => { + const cells = fittedColumnWidths.map((width, columnIndex) => { + return formatCell(normalizeCell(row[columnIndex] ?? ''), width) + }) + return `|${cells.join('|')}|` + } + const separator = `|${fittedColumnWidths.map(width => ` ${'-'.repeat(width)} `).join('|')}|` + + return [formatRow(columns), separator, ...rows.map(formatRow)].join('\n') + }, + } +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000000..564a5990051 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 00000000000..6c7353fc4e0 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from '@primer/vitest-config/config' + +export default defineConfig({ + test: { + environment: 'node', + }, +})