From 080ce0931a47de30cd97d0e415b289e6c1d14c7c Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 29 Apr 2026 11:00:26 -0500 Subject: [PATCH 1/8] Add Primer CLI package scaffold Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 132 ++++++++++++++++++++++++++++++- packages/cli/README.md | 9 +++ packages/cli/bin/primer.js | 5 ++ packages/cli/package.json | 38 +++++++++ packages/cli/src/cli.ts | 102 ++++++++++++++++++++++++ packages/cli/src/index.ts | 1 + packages/cli/tsconfig.build.json | 7 ++ packages/cli/tsconfig.json | 4 + 8 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 packages/cli/README.md create mode 100755 packages/cli/bin/primer.js create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/cli.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/tsconfig.build.json create mode 100644 packages/cli/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 7f884cfc3f1..189a44bffb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@primer/react": "38.21.0", + "@primer/react": "38.21.1", "@primer/styled-react": "1.0.6", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", @@ -95,7 +95,7 @@ "name": "example-nextjs", "version": "0.0.0", "dependencies": { - "@primer/react": "38.21.0", + "@primer/react": "38.21.1", "@primer/styled-react": "1.0.6", "next": "^16.1.7", "react": "^19.2.0", @@ -138,7 +138,7 @@ "version": "0.0.0", "dependencies": { "@primer/octicons-react": "^19.21.0", - "@primer/react": "38.21.0", + "@primer/react": "38.21.1", "@primer/styled-react": "1.0.6", "clsx": "^2.1.1", "next": "^16.1.7", @@ -6743,6 +6743,10 @@ "integrity": "sha512-93juWZbWg2DRhC11+7RT7hMpY1VD3lBosLmccqEZ65yrCHqkBCjI8Uj8wxs3y0U+wWE07LAoLHAPylyWbifg5A==", "license": "MIT" }, + "node_modules/@primer/cli": { + "resolved": "packages/cli", + "link": true + }, "node_modules/@primer/css": { "version": "21.5.1", "dev": true, @@ -27461,6 +27465,126 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/cli": { + "name": "@primer/cli", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@primer/primitives": "10.x || 11.x", + "@primer/react": "^38.20.0" + }, + "bin": { + "primer": "bin/primer.js" + }, + "devDependencies": { + "rimraf": "^6.0.1", + "typescript": "^5.9.2" + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 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.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "packages/cli/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "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", @@ -27872,7 +27996,7 @@ }, "packages/react": { "name": "@primer/react", - "version": "38.21.0", + "version": "38.21.1", "license": "MIT", "dependencies": { "@github/mini-throttle": "^2.1.1", diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 00000000000..0b707214427 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,9 @@ +# Primer CLI + +`@primer/cli` is a command line interface for querying information about Primer. + +```sh +primer --help +``` + +The CLI is intended to be a homebase for agents that need information about Primer components, APIs, source, docs, and design tokens. diff --git a/packages/cli/bin/primer.js b/packages/cli/bin/primer.js new file mode 100755 index 00000000000..15b304c3e86 --- /dev/null +++ b/packages/cli/bin/primer.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import {run} from '../dist/cli.js' + +process.exitCode = await run(process.argv.slice(2)) diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000000..5ec8274f949 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,38 @@ +{ + "name": "@primer/cli", + "description": "A command line interface for querying information about Primer", + "version": "0.0.0", + "type": "module", + "bin": { + "primer": "./bin/primer.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "bin", + "dist", + "README.md" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/primer/react.git" + }, + "scripts": { + "clean": "rimraf dist", + "build": "tsc -p tsconfig.build.json", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@primer/primitives": "10.x || 11.x", + "@primer/react": "^38.20.0" + }, + "devDependencies": { + "rimraf": "^6.0.1", + "typescript": "^5.9.2" + }, + "license": "MIT" +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 00000000000..a29abdc5b74 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,102 @@ +interface Command { + readonly name: string + readonly usage: string + readonly description: string + readonly run: (args: readonly string[]) => Promise | number +} + +const commands: readonly Command[] = [ + { + name: 'components list', + usage: 'primer components list', + description: 'List components in the Primer design system.', + run: notImplemented('components list'), + }, + { + name: 'components get', + usage: 'primer components get ', + description: 'Get usage docs, source, and API info for a component.', + run: notImplemented('components get'), + }, + { + name: 'tokens list', + usage: 'primer tokens list', + description: 'List design tokens in the Primer design system.', + run: notImplemented('tokens list'), + }, + { + name: 'tokens get', + usage: 'primer tokens get ', + description: 'Get guidance for when to use a design token.', + run: notImplemented('tokens get'), + }, +] + +function notImplemented(name: string): () => number { + return () => { + writeError(`The "${name}" command is not implemented yet.`) + return 1 + } +} + +function formatHelp(): string { + const commandList = commands + .map(command => { + return ` ${command.usage.padEnd(32)} ${command.description}` + }) + .join('\n') + + return `Primer CLI + +Usage: + primer --help + primer + +Commands: +${commandList} +` +} + +function findCommand(args: readonly string[]): {command: Command; rest: readonly string[]} | null { + for (const command of commands) { + const parts = command.name.split(' ') + const matches = parts.every((part, index) => { + return args[index] === part + }) + + if (matches) { + return { + command, + rest: args.slice(parts.length), + } + } + } + + return null +} + +async function run(args: readonly string[]): Promise { + if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + writeOutput(formatHelp()) + return 0 + } + + const match = findCommand(args) + if (!match) { + writeError(`Unknown command: ${args.join(' ')}`) + writeError('Run `primer --help` for a list of commands.') + return 1 + } + + return match.command.run(match.rest) +} + +function writeOutput(message: string): void { + process.stdout.write(`${message}\n`) +} + +function writeError(message: string): void { + process.stderr.write(`${message}\n`) +} + +export {formatHelp, run} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 00000000000..394658154d5 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1 @@ +export {run} from './cli' diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json new file mode 100644 index 00000000000..a0bfcab3e31 --- /dev/null +++ b/packages/cli/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + } +} 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"] +} From 95ab01998164eea2b727f5f20a608efdf0478ab9 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 29 Apr 2026 11:02:03 -0500 Subject: [PATCH 2/8] Add Primer CLI component commands Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/cli/src/cli.ts | 25 +++- packages/cli/src/components.ts | 248 +++++++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 +- 3 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/components.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a29abdc5b74..6dcb6dd5dac 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,3 +1,5 @@ +import {formatComponentInfo, formatComponentList, getComponentInfo, listComponents} from './components.js' + interface Command { readonly name: string readonly usage: string @@ -10,13 +12,32 @@ const commands: readonly Command[] = [ name: 'components list', usage: 'primer components list', description: 'List components in the Primer design system.', - run: notImplemented('components list'), + run: () => { + writeOutput(formatComponentList(listComponents())) + return 0 + }, }, { name: 'components get', usage: 'primer components get ', description: 'Get usage docs, source, and API info for a component.', - run: notImplemented('components get'), + run: async args => { + const name = args.join(' ') + if (!name) { + writeError('Usage: primer components get ') + return 1 + } + + const info = await getComponentInfo(name) + if (!info) { + writeError(`Unable to find a Primer component named "${name}".`) + writeError('Run `primer components list` for a list of components.') + return 1 + } + + writeOutput(formatComponentInfo(info)) + return 0 + }, }, { name: 'tokens list', diff --git a/packages/cli/src/components.ts b/packages/cli/src/components.ts new file mode 100644 index 00000000000..9faaad6785c --- /dev/null +++ b/packages/cli/src/components.ts @@ -0,0 +1,248 @@ +import {existsSync, readdirSync, readFileSync} from 'node:fs' +import {createRequire} from 'node:module' +import path from 'node:path' +import {fileURLToPath} from 'node:url' + +interface ComponentProp { + readonly name: string + readonly type?: string + readonly required?: boolean + readonly description?: string + readonly defaultValue?: string + readonly deprecated?: boolean +} + +interface ComponentStory { + readonly id: string + readonly code?: string +} + +interface ComponentPassthrough { + readonly element: string + readonly url: string +} + +interface Component { + readonly id: string + readonly name: string + readonly status?: string + readonly importPath: string + readonly source?: string + readonly props?: readonly ComponentProp[] + readonly stories?: readonly ComponentStory[] + readonly passthrough?: ComponentPassthrough +} + +interface ComponentsData { + readonly components: Record +} + +interface ComponentInfo { + readonly component: Component + readonly docsUrl: string + readonly usageDocs: string +} + +const require = createRequire(import.meta.url) + +function listComponents(): readonly Component[] { + return Object.values(loadComponentsData().components).sort((a, b) => { + return a.name.localeCompare(b.name) + }) +} + +function findComponent(name: string): Component | null { + const normalizedName = normalizeIdentifier(name) + return ( + listComponents().find(component => { + return ( + normalizeIdentifier(component.name) === normalizedName || + normalizeIdentifier(component.id) === normalizedName || + normalizeIdentifier(idToSlug(component.id)) === normalizedName + ) + }) ?? null + ) +} + +async function getComponentInfo(name: string): Promise { + const component = findComponent(name) + if (!component) { + return null + } + + const docsUrl = new URL(`/product/components/${idToSlug(component.id)}`, 'https://primer.style') + const llmsUrl = new URL(`${docsUrl.pathname}/llms.txt`, docsUrl) + let usageDocs = `Usage documentation is available at ${docsUrl.toString()}` + + try { + const response = await fetch(llmsUrl) + if (response.ok) { + usageDocs = await response.text() + } else { + usageDocs = `Unable to fetch ${llmsUrl.toString()} (${response.status} ${response.statusText}). ${usageDocs}` + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + usageDocs = `Unable to fetch ${llmsUrl.toString()} (${message}). ${usageDocs}` + } + + return { + component, + docsUrl: docsUrl.toString(), + usageDocs, + } +} + +function formatComponentList(components: readonly Component[]): string { + return components + .map(component => { + const status = component.status ? ` ${component.status}` : '' + return `${component.name} (${component.id}) - ${component.importPath}${status}` + }) + .join('\n') +} + +function formatComponentInfo(info: ComponentInfo): string { + const {component} = info + const props = component.props ?? [] + const stories = component.stories ?? [] + const metadata = [ + `Import: \`${component.importPath}\``, + component.status ? `Status: ${component.status}` : null, + component.source ? `Source: ${component.source}` : null, + `Docs: ${info.docsUrl}`, + component.passthrough + ? `Passthrough element: ${component.passthrough.element} (${component.passthrough.url})` + : null, + ].filter(Boolean) + + const api = props.length > 0 ? formatProps(props) : 'No API metadata is available for this component.' + const storyList = + stories.length > 0 + ? stories + .map(story => { + return `- ${story.id}` + }) + .join('\n') + : 'No Storybook story metadata is available for this component.' + + return `# ${component.name} + +${metadata.join('\n')} + +## Usage docs + +${info.usageDocs} + +## API + +${api} + +## Stories + +${storyList} +` +} + +function formatProps(props: readonly ComponentProp[]): string { + return props + .map(prop => { + const labels = [prop.required ? 'required' : null, prop.deprecated ? 'deprecated' : null].filter(Boolean) + const suffix = labels.length > 0 ? ` (${labels.join(', ')})` : '' + const defaultValue = prop.defaultValue ? ` Default: \`${prop.defaultValue}\`.` : '' + const description = prop.description ? ` ${prop.description}` : '' + return `- \`${prop.name}\`${suffix}: ${prop.type ?? 'unknown'}.${defaultValue}${description}` + }) + .join('\n') +} + +function loadComponentsData(): ComponentsData { + try { + const componentsPath = require.resolve('@primer/react/generated/components.json') + return JSON.parse(readFileSync(componentsPath, 'utf-8')) as ComponentsData + } catch { + return loadComponentsDataFromSource() + } +} + +function loadComponentsDataFromSource(): ComponentsData { + const repoRoot = findRepositoryRoot() + if (!repoRoot) { + throw new Error('Unable to find @primer/react generated metadata or local source metadata.') + } + + const reactRoot = path.join(repoRoot, 'packages/react') + const sourceRoot = path.join(reactRoot, 'src') + const docsFiles = collectFiles(sourceRoot, '.docs.json') + const components: Record = {} + + for (const file of docsFiles) { + const component = JSON.parse(readFileSync(file, 'utf-8')) as Component + const relativePath = path.relative(reactRoot, path.dirname(file)) + components[component.id] = { + source: `https://github.com/primer/react/tree/main/packages/react/${relativePath}`, + ...component, + } + } + + return {components} +} + +function collectFiles(directory: string, suffix: string): string[] { + if (!existsSync(directory)) { + return [] + } + + const files: string[] = [] + for (const entry of readdirSync(directory, {withFileTypes: true})) { + const filepath = path.join(directory, entry.name) + if (entry.isDirectory()) { + files.push(...collectFiles(filepath, suffix)) + } else if (entry.isFile() && entry.name.endsWith(suffix)) { + files.push(filepath) + } + } + + return files +} + +function findRepositoryRoot(): string | null { + let directory = path.dirname(fileURLToPath(import.meta.url)) + + while (directory !== path.dirname(directory)) { + const packageJsonPath = path.join(directory, 'package.json') + if (existsSync(packageJsonPath)) { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as {name?: string} + if (packageJson.name === 'primer') { + return directory + } + } + + directory = path.dirname(directory) + } + + return null +} + +function idToSlug(id: string): string { + if (id === 'actionbar') { + return 'action-bar' + } + + if (id === 'tooltip-v2') { + return 'tooltip' + } + + if (id === 'dialog_v2') { + return 'dialog' + } + + return id.replaceAll('_', '-') +} + +function normalizeIdentifier(value: string): string { + return value.toLowerCase().replaceAll(/[\s_-]/g, '') +} + +export {findComponent, formatComponentInfo, formatComponentList, getComponentInfo, listComponents} +export type {Component} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 394658154d5..23f632e9264 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1 +1 @@ -export {run} from './cli' +export {run} from './cli.js' From da59f7854a3250c0d68ac4b37dca2bfffd7028db Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 29 Apr 2026 11:03:17 -0500 Subject: [PATCH 3/8] Add Primer CLI token commands Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/cli/src/cli.ts | 55 +++++++-- packages/cli/src/tokens.ts | 226 +++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/tokens.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6dcb6dd5dac..e649b337ebc 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,4 +1,5 @@ import {formatComponentInfo, formatComponentList, getComponentInfo, listComponents} from './components.js' +import {formatTokenInfo, formatTokenList, getTokenInfo, listTokens} from './tokens.js' interface Command { readonly name: string @@ -41,23 +42,63 @@ const commands: readonly Command[] = [ }, { name: 'tokens list', - usage: 'primer tokens list', + usage: 'primer tokens list [--group ]', description: 'List design tokens in the Primer design system.', - run: notImplemented('tokens list'), + run: args => { + const options = parseTokenListOptions(args) + if (!options) { + writeError('Usage: primer tokens list [--group ]') + return 1 + } + + writeOutput(formatTokenList(listTokens(options))) + return 0 + }, }, { name: 'tokens get', usage: 'primer tokens get ', description: 'Get guidance for when to use a design token.', - run: notImplemented('tokens get'), + run: args => { + const name = args.join(' ') + if (!name) { + writeError('Usage: primer tokens get ') + return 1 + } + + const info = getTokenInfo(name) + if (!info) { + writeError(`Unable to find a Primer design token named "${name}".`) + writeError('Run `primer tokens list` for a list of tokens.') + return 1 + } + + writeOutput(formatTokenInfo(info)) + return 0 + }, }, ] -function notImplemented(name: string): () => number { - return () => { - writeError(`The "${name}" command is not implemented yet.`) - return 1 +function parseTokenListOptions(args: readonly string[]): {group?: string} | null { + const options: {group?: string} = {} + + for (let index = 0; index < args.length; index++) { + const arg = args[index] + if (arg === '--group') { + const group = args[index + 1] + if (!group) { + return null + } + + options.group = group + index++ + continue + } + + return null } + + return options } function formatHelp(): string { diff --git a/packages/cli/src/tokens.ts b/packages/cli/src/tokens.ts new file mode 100644 index 00000000000..5df78ed6ce1 --- /dev/null +++ b/packages/cli/src/tokens.ts @@ -0,0 +1,226 @@ +import {existsSync, readdirSync, readFileSync} from 'node:fs' +import {createRequire} from 'node:module' +import path from 'node:path' + +interface PrimitiveToken { + readonly name: string + readonly value: unknown + readonly type?: string + readonly description?: string + readonly group: string +} + +interface TokenGuidance { + readonly name: string + readonly group: string + readonly description?: string + readonly useCase?: string + readonly rules?: string +} + +interface TokenInfo { + readonly token: PrimitiveToken + readonly guidance?: TokenGuidance +} + +interface TokenListOptions { + readonly group?: string +} + +const require = createRequire(import.meta.url) + +function listTokens(options: TokenListOptions = {}): readonly PrimitiveToken[] { + const group = options.group?.toLowerCase() + const tokens = loadTokens() + + return tokens + .filter(token => { + return !group || token.group.toLowerCase() === group || token.name.toLowerCase().startsWith(`${group}-`) + }) + .sort((a, b) => { + return a.name.localeCompare(b.name) + }) +} + +function getTokenInfo(name: string): TokenInfo | null { + const normalizedName = normalizeTokenName(name) + const token = loadTokens().find(candidate => { + return normalizeTokenName(candidate.name) === normalizedName + }) + + if (!token) { + return null + } + + return { + token, + guidance: loadTokenGuidance().get(token.name), + } +} + +function formatTokenList(tokens: readonly PrimitiveToken[]): string { + return tokens + .map(token => { + return `${token.name} - ${formatValue(token.value)} (${token.type ?? 'unknown'})` + }) + .join('\n') +} + +function formatTokenInfo(info: TokenInfo): string { + const {token, guidance} = info + const whenToUse = guidance?.useCase ?? token.description ?? guidance?.description ?? 'No usage guidance is available.' + const rules = guidance?.rules ?? 'No token-specific rules are available.' + + return `# ${token.name} + +Value: \`${formatValue(token.value)}\` +Type: ${token.type ?? 'unknown'} +Group: ${token.group} + +## When to use + +${whenToUse} + +## Rules + +${rules} +` +} + +function loadTokens(): readonly PrimitiveToken[] { + const primitivesRoot = getPrimitivesRoot() + const docsRoot = path.join(primitivesRoot, 'dist/docs') + const tokenFiles = collectFiles(docsRoot, '.json') + const tokens = new Map() + + for (const file of tokenFiles) { + const data = JSON.parse(readFileSync(file, 'utf-8')) as Record + for (const token of Object.values(data)) { + if (!token.name || tokens.has(token.name)) { + continue + } + + tokens.set(token.name, { + name: token.name, + value: token.value, + type: token.type, + description: token.description, + group: token.name.split('-')[0], + }) + } + } + + return Array.from(tokens.values()) +} + +function loadTokenGuidance(): ReadonlyMap { + const specPath = path.join(getPrimitivesRoot(), 'DESIGN_TOKENS_SPEC.md') + if (!existsSync(specPath)) { + return new Map() + } + + const tokens = new Map() + const lines = readFileSync(specPath, 'utf-8').split('\n') + let currentGroup = '' + let currentToken: TokenGuidance | null = null + let descriptionLines: string[] = [] + + const saveCurrentToken = () => { + if (!currentToken) { + return + } + + tokens.set(currentToken.name, { + ...currentToken, + description: currentToken.description ?? descriptionLines.join(' '), + }) + descriptionLines = [] + } + + for (const line of lines) { + const groupMatch = line.match(/^## (.+)$/) + if (groupMatch) { + saveCurrentToken() + currentGroup = groupMatch[1].trim() + currentToken = null + continue + } + + const tokenMatch = line.match(/^### (.+)$/) + if (tokenMatch) { + saveCurrentToken() + currentToken = { + name: tokenMatch[1].trim(), + group: currentGroup, + } + continue + } + + const useCaseMatch = line.match(/^\*\*U:\*\*\s*(.+)$/) + if (useCaseMatch && currentToken) { + currentToken = { + ...currentToken, + useCase: useCaseMatch[1].trim(), + } + continue + } + + const rulesMatch = line.match(/^\*\*R:\*\*\s*(.+)$/) + if (rulesMatch && currentToken) { + currentToken = { + ...currentToken, + rules: rulesMatch[1].trim(), + } + continue + } + + if (currentToken && !currentToken.useCase && line.trim() && !line.startsWith('#') && !line.startsWith('**')) { + descriptionLines.push(line.trim()) + } + } + + saveCurrentToken() + return tokens +} + +function collectFiles(directory: string, suffix: string): string[] { + if (!existsSync(directory)) { + return [] + } + + const files: string[] = [] + for (const entry of readdirSync(directory, {withFileTypes: true})) { + const filepath = path.join(directory, entry.name) + if (entry.isDirectory()) { + files.push(...collectFiles(filepath, suffix)) + } else if (entry.isFile() && entry.name.endsWith(suffix)) { + files.push(filepath) + } + } + + return files +} + +function getPrimitivesRoot(): string { + return path.dirname(require.resolve('@primer/primitives/package.json')) +} + +function normalizeTokenName(name: string): string { + return name + .trim() + .replace(/^var\((--)?/, '') + .replace(/\)$/, '') + .replace(/^--/, '') + .toLowerCase() +} + +function formatValue(value: unknown): string { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + + return JSON.stringify(value) +} + +export {formatTokenInfo, formatTokenList, getTokenInfo, listTokens} +export type {PrimitiveToken} From 966843daa4a28f16dfc615bcf13f3315d5bce563 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 29 Apr 2026 11:35:18 -0500 Subject: [PATCH 4/8] Add table and JSON output modes to Primer CLI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/cli/README.md | 11 ++++ packages/cli/src/cli.ts | 76 ++++++++++++++++++++------ packages/cli/src/components.ts | 98 +++++++++++++++++++++++----------- packages/cli/src/table.ts | 44 +++++++++++++++ packages/cli/src/tokens.ts | 46 +++++++++------- 5 files changed, 209 insertions(+), 66 deletions(-) create mode 100644 packages/cli/src/table.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 0b707214427..f031973992a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4,6 +4,17 @@ ```sh primer --help +primer components list +primer components get Button +primer tokens list --group bgColor +primer tokens get bgColor-default +``` + +Commands print tables by default. Add `--json` anywhere in the command to return JSON instead: + +```sh +primer --json components list +primer tokens get --json bgColor-default ``` The CLI is intended to be a homebase for agents that need information about Primer components, APIs, source, docs, and design tokens. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e649b337ebc..2f7fb2bb172 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,11 +1,20 @@ import {formatComponentInfo, formatComponentList, getComponentInfo, listComponents} from './components.js' import {formatTokenInfo, formatTokenList, getTokenInfo, listTokens} from './tokens.js' +interface GlobalOptions { + readonly json: boolean +} + +interface ParsedGlobalOptions { + readonly args: readonly string[] + readonly options: GlobalOptions +} + interface Command { readonly name: string readonly usage: string readonly description: string - readonly run: (args: readonly string[]) => Promise | number + readonly run: (args: readonly string[], options: GlobalOptions) => Promise | number } const commands: readonly Command[] = [ @@ -13,8 +22,9 @@ const commands: readonly Command[] = [ name: 'components list', usage: 'primer components list', description: 'List components in the Primer design system.', - run: () => { - writeOutput(formatComponentList(listComponents())) + run: (_args, options) => { + const components = listComponents() + writeOutput(options.json ? formatJson(components) : formatComponentList(components)) return 0 }, }, @@ -22,7 +32,7 @@ const commands: readonly Command[] = [ name: 'components get', usage: 'primer components get ', description: 'Get usage docs, source, and API info for a component.', - run: async args => { + run: async (args, options) => { const name = args.join(' ') if (!name) { writeError('Usage: primer components get ') @@ -36,7 +46,7 @@ const commands: readonly Command[] = [ return 1 } - writeOutput(formatComponentInfo(info)) + writeOutput(options.json ? formatJson(info) : formatComponentInfo(info)) return 0 }, }, @@ -44,14 +54,15 @@ const commands: readonly Command[] = [ name: 'tokens list', usage: 'primer tokens list [--group ]', description: 'List design tokens in the Primer design system.', - run: args => { - const options = parseTokenListOptions(args) - if (!options) { + run: (args, options) => { + const tokenOptions = parseTokenListOptions(args) + if (!tokenOptions) { writeError('Usage: primer tokens list [--group ]') return 1 } - writeOutput(formatTokenList(listTokens(options))) + const tokens = listTokens(tokenOptions) + writeOutput(options.json ? formatJson(tokens) : formatTokenList(tokens)) return 0 }, }, @@ -59,7 +70,7 @@ const commands: readonly Command[] = [ name: 'tokens get', usage: 'primer tokens get ', description: 'Get guidance for when to use a design token.', - run: args => { + run: (args, options) => { const name = args.join(' ') if (!name) { writeError('Usage: primer tokens get ') @@ -73,7 +84,7 @@ const commands: readonly Command[] = [ return 1 } - writeOutput(formatTokenInfo(info)) + writeOutput(options.json ? formatJson(info) : formatTokenInfo(info)) return 0 }, }, @@ -112,13 +123,40 @@ function formatHelp(): string { Usage: primer --help - primer + primer [--json] Commands: ${commandList} ` } +function parseGlobalOptions(args: readonly string[]): ParsedGlobalOptions { + return args.reduce( + (parsed, arg) => { + if (arg === '--json') { + return { + args: parsed.args, + options: { + ...parsed.options, + json: true, + }, + } + } + + return { + ...parsed, + args: [...parsed.args, arg], + } + }, + { + args: [], + options: { + json: false, + }, + }, + ) +} + function findCommand(args: readonly string[]): {command: Command; rest: readonly string[]} | null { for (const command of commands) { const parts = command.name.split(' ') @@ -138,19 +176,25 @@ function findCommand(args: readonly string[]): {command: Command; rest: readonly } async function run(args: readonly string[]): Promise { - if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + const parsed = parseGlobalOptions(args) + + if (parsed.args.length === 0 || parsed.args.includes('--help') || parsed.args.includes('-h')) { writeOutput(formatHelp()) return 0 } - const match = findCommand(args) + const match = findCommand(parsed.args) if (!match) { - writeError(`Unknown command: ${args.join(' ')}`) + writeError(`Unknown command: ${parsed.args.join(' ')}`) writeError('Run `primer --help` for a list of commands.') return 1 } - return match.command.run(match.rest) + return match.command.run(match.rest, parsed.options) +} + +function formatJson(value: unknown): string { + return JSON.stringify(value, null, 2) } function writeOutput(message: string): void { diff --git a/packages/cli/src/components.ts b/packages/cli/src/components.ts index 9faaad6785c..bff75df3040 100644 --- a/packages/cli/src/components.ts +++ b/packages/cli/src/components.ts @@ -2,6 +2,7 @@ import {existsSync, readdirSync, readFileSync} from 'node:fs' import {createRequire} from 'node:module' import path from 'node:path' import {fileURLToPath} from 'node:url' +import {formatKeyValueTable, formatTable} from './table.js' interface ComponentProp { readonly name: string @@ -94,45 +95,63 @@ async function getComponentInfo(name: string): Promise { } function formatComponentList(components: readonly Component[]): string { - return components - .map(component => { - const status = component.status ? ` ${component.status}` : '' - return `${component.name} (${component.id}) - ${component.importPath}${status}` - }) - .join('\n') + return formatTable(components, [ + { + header: 'Name', + getValue: component => component.name, + }, + { + header: 'ID', + getValue: component => component.id, + }, + { + header: 'Import path', + getValue: component => component.importPath, + }, + { + header: 'Status', + getValue: component => component.status, + }, + ]) } function formatComponentInfo(info: ComponentInfo): string { const {component} = info const props = component.props ?? [] const stories = component.stories ?? [] - const metadata = [ - `Import: \`${component.importPath}\``, - component.status ? `Status: ${component.status}` : null, - component.source ? `Source: ${component.source}` : null, - `Docs: ${info.docsUrl}`, - component.passthrough - ? `Passthrough element: ${component.passthrough.element} (${component.passthrough.url})` - : null, - ].filter(Boolean) + const metadata = formatKeyValueTable([ + ['Name', component.name], + ['ID', component.id], + ['Import path', component.importPath], + ['Status', component.status], + ['Source', component.source], + ['Docs', info.docsUrl], + [ + 'Passthrough element', + component.passthrough ? `${component.passthrough.element} (${component.passthrough.url})` : undefined, + ], + ]) const api = props.length > 0 ? formatProps(props) : 'No API metadata is available for this component.' const storyList = stories.length > 0 - ? stories - .map(story => { - return `- ${story.id}` - }) - .join('\n') + ? formatTable(stories, [ + { + header: 'Story', + getValue: story => story.id, + }, + ]) : 'No Storybook story metadata is available for this component.' return `# ${component.name} -${metadata.join('\n')} +## Component + +${metadata} ## Usage docs -${info.usageDocs} +${formatKeyValueTable([['Usage docs', info.usageDocs]])} ## API @@ -145,15 +164,32 @@ ${storyList} } function formatProps(props: readonly ComponentProp[]): string { - return props - .map(prop => { - const labels = [prop.required ? 'required' : null, prop.deprecated ? 'deprecated' : null].filter(Boolean) - const suffix = labels.length > 0 ? ` (${labels.join(', ')})` : '' - const defaultValue = prop.defaultValue ? ` Default: \`${prop.defaultValue}\`.` : '' - const description = prop.description ? ` ${prop.description}` : '' - return `- \`${prop.name}\`${suffix}: ${prop.type ?? 'unknown'}.${defaultValue}${description}` - }) - .join('\n') + return formatTable(props, [ + { + header: 'Prop', + getValue: prop => prop.name, + }, + { + header: 'Type', + getValue: prop => prop.type, + }, + { + header: 'Required', + getValue: prop => (prop.required ? 'Yes' : 'No'), + }, + { + header: 'Deprecated', + getValue: prop => (prop.deprecated ? 'Yes' : 'No'), + }, + { + header: 'Default', + getValue: prop => prop.defaultValue, + }, + { + header: 'Description', + getValue: prop => prop.description, + }, + ]) } function loadComponentsData(): ComponentsData { diff --git a/packages/cli/src/table.ts b/packages/cli/src/table.ts new file mode 100644 index 00000000000..df1e37fc80b --- /dev/null +++ b/packages/cli/src/table.ts @@ -0,0 +1,44 @@ +type TableValue = string | number | boolean | null | undefined + +interface TableColumn { + readonly header: string + readonly getValue: (item: T) => TableValue +} + +function formatTable(items: readonly T[], columns: readonly TableColumn[]): string { + const header = `| ${columns.map(column => escapeCell(column.header)).join(' | ')} |` + const separator = `| ${columns.map(() => '---').join(' | ')} |` + const rows = items.map(item => { + return `| ${columns + .map(column => { + return escapeCell(column.getValue(item)) + }) + .join(' | ')} |` + }) + + return [header, separator, ...rows].join('\n') +} + +function formatKeyValueTable(rows: readonly (readonly [string, TableValue])[]): string { + return formatTable(rows, [ + { + header: 'Field', + getValue: row => row[0], + }, + { + header: 'Value', + getValue: row => row[1], + }, + ]) +} + +function escapeCell(value: TableValue): string { + if (value === null || value === undefined || value === '') { + return '-' + } + + return String(value).replaceAll('|', '\\|').replaceAll('\n', '\\n') +} + +export {formatKeyValueTable, formatTable} +export type {TableColumn, TableValue} diff --git a/packages/cli/src/tokens.ts b/packages/cli/src/tokens.ts index 5df78ed6ce1..4ab152f00cf 100644 --- a/packages/cli/src/tokens.ts +++ b/packages/cli/src/tokens.ts @@ -1,6 +1,7 @@ import {existsSync, readdirSync, readFileSync} from 'node:fs' import {createRequire} from 'node:module' import path from 'node:path' +import {formatKeyValueTable, formatTable} from './table.js' interface PrimitiveToken { readonly name: string @@ -59,11 +60,24 @@ function getTokenInfo(name: string): TokenInfo | null { } function formatTokenList(tokens: readonly PrimitiveToken[]): string { - return tokens - .map(token => { - return `${token.name} - ${formatValue(token.value)} (${token.type ?? 'unknown'})` - }) - .join('\n') + return formatTable(tokens, [ + { + header: 'Name', + getValue: token => token.name, + }, + { + header: 'Value', + getValue: token => formatValue(token.value), + }, + { + header: 'Type', + getValue: token => token.type, + }, + { + header: 'Group', + getValue: token => token.group, + }, + ]) } function formatTokenInfo(info: TokenInfo): string { @@ -71,20 +85,14 @@ function formatTokenInfo(info: TokenInfo): string { const whenToUse = guidance?.useCase ?? token.description ?? guidance?.description ?? 'No usage guidance is available.' const rules = guidance?.rules ?? 'No token-specific rules are available.' - return `# ${token.name} - -Value: \`${formatValue(token.value)}\` -Type: ${token.type ?? 'unknown'} -Group: ${token.group} - -## When to use - -${whenToUse} - -## Rules - -${rules} -` + return formatKeyValueTable([ + ['Name', token.name], + ['Value', formatValue(token.value)], + ['Type', token.type], + ['Group', token.group], + ['When to use', whenToUse], + ['Rules', rules], + ]) } function loadTokens(): readonly PrimitiveToken[] { From b0ed05a7239ab1baa42abd76290618433443c874 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 29 Apr 2026 11:38:58 -0500 Subject: [PATCH 5/8] Use llms component docs URLs in Primer CLI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/cli/src/components.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/components.ts b/packages/cli/src/components.ts index bff75df3040..62302f883e8 100644 --- a/packages/cli/src/components.ts +++ b/packages/cli/src/components.ts @@ -71,20 +71,19 @@ async function getComponentInfo(name: string): Promise { return null } - const docsUrl = new URL(`/product/components/${idToSlug(component.id)}`, 'https://primer.style') - const llmsUrl = new URL(`${docsUrl.pathname}/llms.txt`, docsUrl) + const docsUrl = new URL(`/product/components/${idToSlug(component.id)}/llms.txt`, 'https://primer.style') let usageDocs = `Usage documentation is available at ${docsUrl.toString()}` try { - const response = await fetch(llmsUrl) + const response = await fetch(docsUrl) if (response.ok) { usageDocs = await response.text() } else { - usageDocs = `Unable to fetch ${llmsUrl.toString()} (${response.status} ${response.statusText}). ${usageDocs}` + usageDocs = `Unable to fetch ${docsUrl.toString()} (${response.status} ${response.statusText}). ${usageDocs}` } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error) - usageDocs = `Unable to fetch ${llmsUrl.toString()} (${message}). ${usageDocs}` + usageDocs = `Unable to fetch ${docsUrl.toString()} (${message}). ${usageDocs}` } return { From 328960dd3f7afe3af1b657f0beeaa8a52c9f5b05 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Mon, 18 May 2026 14:06:07 -0500 Subject: [PATCH 6/8] Add private flag to package.json --- packages/cli/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/package.json b/packages/cli/package.json index 5ec8274f949..5d9cadb25e3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,7 @@ { "name": "@primer/cli", "description": "A command line interface for querying information about Primer", + "private": true, "version": "0.0.0", "type": "module", "bin": { From 225a1588d31062bacbaadde092046d81875fd0e6 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Mon, 18 May 2026 16:38:44 -0500 Subject: [PATCH 7/8] chore(deps): update typescript --- package-lock.json | 16 +--------------- packages/cli/package.json | 2 +- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index e9750999ef9..63aa62a4fd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27317,7 +27317,7 @@ }, "devDependencies": { "rimraf": "^6.0.1", - "typescript": "^5.9.2" + "typescript": "^6.0.3" } }, "packages/cli/node_modules/balanced-match": { @@ -27424,20 +27424,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "packages/cli/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "packages/doc-gen": { "name": "@primer/doc-gen", "version": "0.0.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index 5ec8274f949..a859dfa3f73 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "rimraf": "^6.0.1", - "typescript": "^5.9.2" + "typescript": "^6.0.3" }, "license": "MIT" } From 5cca355c5f963e6bcd304c22ab5c3352b6476562 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 21:44:26 +0000 Subject: [PATCH 8/8] Add primer CLI icon commands for octicons-react Co-authored-by: joshblack <3901764+joshblack@users.noreply.github.com> --- packages/cli/README.md | 5 +- packages/cli/src/cli.ts | 33 +++++++++++++ packages/cli/src/icons.ts | 97 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/icons.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index f031973992a..5546b47bcdb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -6,6 +6,8 @@ primer --help primer components list primer components get Button +primer icons list +primer icons get alert primer tokens list --group bgColor primer tokens get bgColor-default ``` @@ -14,7 +16,8 @@ Commands print tables by default. Add `--json` anywhere in the command to return ```sh primer --json components list +primer --json icons get alert primer tokens get --json bgColor-default ``` -The CLI is intended to be a homebase for agents that need information about Primer components, APIs, source, docs, and design tokens. +The CLI is intended to be a homebase for agents that need information about Primer components, icons, APIs, source, docs, and design tokens. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2f7fb2bb172..311489a5a4c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,4 +1,5 @@ import {formatComponentInfo, formatComponentList, getComponentInfo, listComponents} from './components.js' +import {formatIconInfo, formatIconList, getIconInfo, listIcons} from './icons.js' import {formatTokenInfo, formatTokenList, getTokenInfo, listTokens} from './tokens.js' interface GlobalOptions { @@ -88,6 +89,38 @@ const commands: readonly Command[] = [ return 0 }, }, + { + name: 'icons list', + usage: 'primer icons list', + description: 'List icons in the @primer/octicons-react package.', + run: (_args, options) => { + const icons = listIcons() + writeOutput(options.json ? formatJson(icons) : formatIconList(icons)) + return 0 + }, + }, + { + name: 'icons get', + usage: 'primer icons get ', + description: 'Get docs and import info for an icon.', + run: (args, options) => { + const name = args.join(' ') + if (!name) { + writeError('Usage: primer icons get ') + return 1 + } + + const info = getIconInfo(name) + if (!info) { + writeError(`Unable to find an icon named "${name}" in @primer/octicons-react.`) + writeError('Run `primer icons list` for a list of icons.') + return 1 + } + + writeOutput(options.json ? formatJson(info) : formatIconInfo(info)) + return 0 + }, + }, ] function parseTokenListOptions(args: readonly string[]): {group?: string} | null { diff --git a/packages/cli/src/icons.ts b/packages/cli/src/icons.ts new file mode 100644 index 00000000000..a3c73fb66d1 --- /dev/null +++ b/packages/cli/src/icons.ts @@ -0,0 +1,97 @@ +import {createRequire} from 'node:module' +import {formatKeyValueTable, formatTable} from './table.js' + +interface Icon { + readonly name: string + readonly exportName: string + readonly importPath: string +} + +interface IconInfo { + readonly icon: Icon + readonly docsUrl: string +} + +const require = createRequire(import.meta.url) + +function listIcons(): readonly Icon[] { + const octicons = require('@primer/octicons-react') as Record + + return Object.keys(octicons) + .filter((name): name is `${string}Icon` => { + return name.endsWith('Icon') + }) + .map(exportName => { + return { + name: exportNameToIconName(exportName), + exportName, + importPath: '@primer/octicons-react', + } + }) + .sort((a, b) => { + return a.name.localeCompare(b.name) + }) +} + +function getIconInfo(name: string): IconInfo | null { + const normalizedName = normalizeIdentifier(name) + const icon = listIcons().find(candidate => { + return ( + normalizeIdentifier(candidate.name) === normalizedName || + normalizeIdentifier(candidate.exportName) === normalizedName || + normalizeIdentifier(candidate.exportName.replace(/Icon$/, '')) === normalizedName + ) + }) + + if (!icon) { + return null + } + + return { + icon, + docsUrl: new URL(`/octicons/icon/${icon.name}-16`, 'https://primer.style').toString(), + } +} + +function formatIconList(icons: readonly Icon[]): string { + return formatTable(icons, [ + { + header: 'Name', + getValue: icon => icon.name, + }, + { + header: 'Export', + getValue: icon => icon.exportName, + }, + { + header: 'Import path', + getValue: icon => icon.importPath, + }, + ]) +} + +function formatIconInfo(info: IconInfo): string { + const {icon} = info + + return formatKeyValueTable([ + ['Name', icon.name], + ['Export', icon.exportName], + ['Import path', icon.importPath], + ['Docs', info.docsUrl], + ]) +} + +function exportNameToIconName(exportName: string): string { + const name = exportName.replace(/Icon$/, '') + return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase() +} + +function normalizeIdentifier(value: string): string { + return value + .toLowerCase() + .replaceAll(/[\s_-]/g, '') + .replace(/icon$/, '') +} + +export {formatIconInfo, formatIconList, getIconInfo, listIcons} +export type {Icon}