From e01298f36389ecf9e9b0f220330b0388a0f34f07 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Tue, 6 Oct 2026 16:03:02 -0500 Subject: [PATCH 01/13] feat(cli): add package folder for @primer/cli --- package-lock.json | 28 +++++++++++++++++++++++ packages/cli/README.md | 1 + packages/cli/bin/primer.js | 3 +++ packages/cli/package.json | 40 +++++++++++++++++++++++++++++++++ packages/cli/rolldown.config.ts | 11 +++++++++ packages/cli/src/index.ts | 12 ++++++++++ packages/cli/tsconfig.json | 4 ++++ packages/cli/vitest.config.ts | 9 ++++++++ 8 files changed, 108 insertions(+) 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/rolldown.config.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/cli/vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 3c0a77110e0..855cd659f4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6683,6 +6683,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, @@ -12142,6 +12146,12 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "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", @@ -30558,6 +30568,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "packages/cli": { + "name": "@primer/cli", + "version": "0.0.0", + "dependencies": { + "citty": "^0.2.2" + }, + "bin": { + "primer": "bin/primer.js" + }, + "devDependencies": { + "@primer/vitest-config": "^0.0.0", + "@types/node": "26.0.0", + "rimraf": "5.0.5", + "rolldown": "^1.2.11", + "typescript": "^6.0.3", + "vitest": "^5.0.0" + } + }, "packages/doc-gen": { "name": "@primer/doc-gen", "version": "0.0.1", diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 00000000000..5dcd62ab78a --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1 @@ +# @primer/cli diff --git a/packages/cli/bin/primer.js b/packages/cli/bin/primer.js new file mode 100755 index 00000000000..f72ea462203 --- /dev/null +++ b/packages/cli/bin/primer.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +import '../dist/index.js' diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000000..329d58038a5 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,40 @@ +{ + "name": "@primer/cli", + "description": "Command-line tools for the Primer Design System", + "version": "0.0.0", + "type": "module", + "bin": { + "primer": "./bin/primer.js" + }, + "files": [ + "bin", + "dist", + "README.md" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/primer/react.git", + "directory": "packages/cli" + }, + "scripts": { + "build": "rolldown -c", + "clean": "rimraf dist", + "prepack": "npm run build", + "type-check": "tsc --noEmit", + "watch": "rolldown -c -w" + }, + "dependencies": { + "citty": "^0.2.2" + }, + "devDependencies": { + "@primer/vitest-config": "^0.0.0", + "@types/node": "26.0.0", + "rimraf": "5.0.5", + "rolldown": "^1.2.11", + "typescript": "^6.0.3", + "vitest": "^5.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/cli/rolldown.config.ts b/packages/cli/rolldown.config.ts new file mode 100644 index 00000000000..e85b508b570 --- /dev/null +++ b/packages/cli/rolldown.config.ts @@ -0,0 +1,11 @@ +import {fileURLToPath} from 'node:url' +import {defineConfig} from 'rolldown' + +export default defineConfig({ + input: fileURLToPath(new URL('./src/index.ts', import.meta.url)), + platform: 'node', + output: { + file: fileURLToPath(new URL('./dist/index.js', import.meta.url)), + format: 'esm', + }, +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 00000000000..d26e19b0379 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,12 @@ +import {defineCommand, runMain} from 'citty' +import packageJson from '../package.json' with {type: 'json'} + +const main = defineCommand({ + meta: { + name: 'primer', + version: packageJson.version, + description: packageJson.description, + }, +}) + +await runMain(main) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000000..23093d59d49 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "rolldown.config.ts", "vitest.config.ts"] +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 00000000000..771c082d9aa --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from '@primer/vitest-config/config' + +export default defineConfig({ + test: { + name: '@primer/cli', + environment: 'node', + detectAsyncLeaks: true, + }, +}) From 1f0f6af5bbf192af55689d2f3f75cc2028fe0efc Mon Sep 17 00:00:00 2001 From: Josh Black Date: Tue, 6 Oct 2026 16:36:53 -0500 Subject: [PATCH 02/13] feat: add support for primer component list --- package-lock.json | 125 +++++++++++++++++++++++- packages/cli/package.json | 4 +- packages/cli/rolldown.config.ts | 2 +- packages/cli/src/component.test.ts | 146 +++++++++++++++++++++++++++++ packages/cli/src/component.ts | 85 +++++++++++++++++ packages/cli/src/console.ts | 3 + packages/cli/src/index.ts | 6 ++ 7 files changed, 367 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/component.test.ts create mode 100644 packages/cli/src/component.ts create mode 100644 packages/cli/src/console.ts diff --git a/package-lock.json b/package-lock.json index 855cd659f4a..7de1b2858ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11995,7 +11995,6 @@ "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", - "dev": true, "license": "MIT" }, "node_modules/character-entities": { @@ -27665,6 +27664,117 @@ "node": ">=10.0.0" } }, + "node_modules/tablemark": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tablemark/-/tablemark-4.1.0.tgz", + "integrity": "sha512-B3LDjbDo+ac+D5RwkBOPZZ6ua8716KdT+6NO3DKOCHJq0ezE6vV2r92rjrC1ci2H+ocuysl5ytf1T0QqV65yoA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2", + "change-case": "^5.4.4", + "string-width": "^8.1.0", + "wordwrapjs": "^5.1.0", + "wrap-ansi": "^9.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tablemark/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/tablemark/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/tablemark/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/tablemark/node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tablemark/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tablemark/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/tablemark/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", @@ -30243,6 +30353,15 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrapjs": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", + "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "dev": true, @@ -30572,12 +30691,14 @@ "name": "@primer/cli", "version": "0.0.0", "dependencies": { - "citty": "^0.2.2" + "citty": "^0.2.2", + "tablemark": "^4.1.0" }, "bin": { "primer": "bin/primer.js" }, "devDependencies": { + "@primer/react": "^38.40.1", "@primer/vitest-config": "^0.0.0", "@types/node": "26.0.0", "rimraf": "5.0.5", diff --git a/packages/cli/package.json b/packages/cli/package.json index 329d58038a5..e58bef9f38c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -24,9 +24,11 @@ "watch": "rolldown -c -w" }, "dependencies": { - "citty": "^0.2.2" + "citty": "^0.2.2", + "tablemark": "^4.1.0" }, "devDependencies": { + "@primer/react": "^38.40.1", "@primer/vitest-config": "^0.0.0", "@types/node": "26.0.0", "rimraf": "5.0.5", diff --git a/packages/cli/rolldown.config.ts b/packages/cli/rolldown.config.ts index e85b508b570..d8064e52fe6 100644 --- a/packages/cli/rolldown.config.ts +++ b/packages/cli/rolldown.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ input: fileURLToPath(new URL('./src/index.ts', import.meta.url)), platform: 'node', output: { - file: fileURLToPath(new URL('./dist/index.js', import.meta.url)), + dir: fileURLToPath(new URL('./dist', import.meta.url)), format: 'esm', }, }) diff --git a/packages/cli/src/component.test.ts b/packages/cli/src/component.test.ts new file mode 100644 index 00000000000..a4685acb4d9 --- /dev/null +++ b/packages/cli/src/component.test.ts @@ -0,0 +1,146 @@ +import {runCommand} from 'citty' +import componentsMetadata from '@primer/react/generated/components.json' with {type: 'json'} +import {afterEach, describe, expect, it, vi} from 'vitest' +import {component} from './component' +import {log} from './console' + +vi.mock('./console') + +const components = Object.values(componentsMetadata.components) + .map(metadata => { + return {id: metadata.id, name: metadata.name} + }) + .toSorted((a, b) => { + return a.name.localeCompare(b.name) + }) + +describe('component list', () => { + const emit = vi.mocked(log) + + afterEach(() => { + emit.mockClear() + }) + + it('lists all components in a Markdown table by default', async () => { + await runCommand(component, {rawArgs: ['list']}) + + expect(emit).toHaveBeenCalledTimes(1) + const output = emit.mock.calls[0][0] + const lines = output.trimEnd().split('\n') + const rows = lines.map(line => { + return line + .split('|') + .slice(1, -1) + .map(cell => { + return cell.trim() + }) + }) + + expect(rows[0]).toEqual(['ID', 'Name']) + expect(rows[1]).toEqual([expect.stringMatching(/^:-+$/), expect.stringMatching(/^:-+$/)]) + expect(rows.slice(2)).toEqual( + components.map(metadata => { + return [metadata.id, metadata.name] + }), + ) + expect( + new Set( + lines.map(line => { + return line.length + }), + ).size, + ).toBe(1) + }) + + it('outputs component IDs and names as a JSON array', async () => { + await runCommand(component, {rawArgs: ['list', '--json']}) + + expect(emit).toHaveBeenCalledExactlyOnceWith(JSON.stringify(components, null, 2)) + }) + + it('uses the first page of 10 components and includes page info when pagination is enabled', async () => { + await runCommand(component, {rawArgs: ['list', '--json', '--paginate']}) + + expect(emit).toHaveBeenCalledExactlyOnceWith( + JSON.stringify( + { + components: components.slice(0, 10), + pagination: {page: 1, limit: 10, total: components.length, totalPages: Math.ceil(components.length / 10)}, + }, + null, + 2, + ), + ) + }) + + it.each([ + {flags: ['--limit', '2'], page: 1, limit: 2, start: 0, end: 2}, + {flags: ['--page', '2'], page: 2, limit: 10, start: 10, end: 20}, + {flags: ['--paginate', '--limit', '2', '--page', '2'], page: 2, limit: 2, start: 2, end: 4}, + {flags: ['--limit', '2', '--page', '2'], page: 2, limit: 2, start: 2, end: 4}, + { + flags: ['--limit', '1', '--page', String(components.length)], + page: components.length, + limit: 1, + start: components.length - 1, + end: components.length, + }, + { + flags: ['--limit', '3', '--page', String(Math.ceil(components.length / 3))], + page: Math.ceil(components.length / 3), + limit: 3, + start: (Math.ceil(components.length / 3) - 1) * 3, + end: components.length, + }, + { + flags: ['--page', String(components.length + 1)], + page: components.length + 1, + limit: 10, + start: components.length, + end: components.length, + }, + ])('paginates JSON with $flags', async ({flags, page, limit, start, end}) => { + await runCommand(component, {rawArgs: ['list', '--json', ...flags]}) + + expect(emit).toHaveBeenCalledExactlyOnceWith( + JSON.stringify( + { + components: components.slice(start, end), + pagination: {page, limit, total: components.length, totalPages: Math.ceil(components.length / limit)}, + }, + null, + 2, + ), + ) + }) + + it('paginates Markdown output', async () => { + await runCommand(component, {rawArgs: ['list', '--limit', '1', '--page', '2']}) + + expect(emit).toHaveBeenCalledExactlyOnceWith( + `| ID | Name |\n| :---------- | :--------- |\n| action_list | ActionList |\n\nPage 2 of ${components.length} (${components.length} components)`, + ) + }) + + it('includes page info for an empty Markdown page', async () => { + const page = components.length + 1 + await runCommand(component, {rawArgs: ['list', '--page', String(page)]}) + + expect(emit).toHaveBeenCalledExactlyOnceWith( + `| ID | Name |\n| --- | --- |\n\nPage ${page} of ${Math.ceil(components.length / 10)} (${components.length} components)`, + ) + }) + + it.each(['limit', 'page'])('rejects invalid --%s values without writing output', async flag => { + for (const value of ['0', '-1', '1.5', 'abc', '1e2', 'Infinity', '9007199254740992', '']) { + await expect(runCommand(component, {rawArgs: ['list', `--${flag}=${value}`]})).rejects.toThrow( + `--${flag} must be a positive safe integer`, + ) + } + + await expect(runCommand(component, {rawArgs: ['list', `--${flag}`]})).rejects.toThrow( + `--${flag} must be a positive safe integer`, + ) + expect(emit).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/src/component.ts b/packages/cli/src/component.ts new file mode 100644 index 00000000000..43beaf8e85d --- /dev/null +++ b/packages/cli/src/component.ts @@ -0,0 +1,85 @@ +import {defineCommand} from 'citty' +import {tablemark} from 'tablemark' +import componentsMetadata from '@primer/react/generated/components.json' with {type: 'json'} +import {log} from './console' + +function parsePositiveInteger(value: string | undefined, name: string, fallback: number): number { + if (value === undefined) { + return fallback + } + + const number = Number(value) + if (!/^[0-9]+$/.test(value) || !Number.isSafeInteger(number) || number < 1) { + throw new Error(`--${name} must be a positive safe integer`) + } + + return number +} + +const list = defineCommand({ + meta: { + name: 'list', + description: 'List the components available from Primer React', + }, + args: { + json: { + type: 'boolean', + description: 'Output JSON instead of a Markdown table', + }, + paginate: { + type: 'boolean', + description: 'Paginate results (default: 10 components per page, starting at page 1)', + }, + limit: { + type: 'string', + description: 'Components per page (enables pagination)', + valueHint: 'number', + }, + page: { + type: 'string', + description: 'Page number, starting at 1 (enables pagination)', + valueHint: 'number', + }, + }, + run({args}) { + const limit = parsePositiveInteger(args.limit, 'limit', 10) + const page = parsePositiveInteger(args.page, 'page', 1) + const components = Object.values(componentsMetadata.components) + .map(component => { + return { + id: component.id, + name: component.name, + } + }) + .toSorted((a, b) => { + return a.name.localeCompare(b.name) + }) + const paginate = args.paginate || args.limit !== undefined || args.page !== undefined + const start = (page - 1) * limit + const results = paginate ? components.slice(start, start + limit) : components + const pagination = { + page, + limit, + total: components.length, + totalPages: Math.ceil(components.length / limit), + } + const output = args.json + ? JSON.stringify(paginate ? {components: results, pagination} : results, null, 2) + : [ + results.length > 0 ? tablemark(results, {columns: ['ID', 'Name']}).trimEnd() : '| ID | Name |\n| --- | --- |', + ...(paginate ? ['', `Page ${page} of ${pagination.totalPages} (${pagination.total} components)`] : []), + ].join('\n') + + log(output) + }, +}) + +export const component = defineCommand({ + meta: { + name: 'component', + description: 'Explore Primer React components', + }, + subCommands: { + list, + }, +}) diff --git a/packages/cli/src/console.ts b/packages/cli/src/console.ts new file mode 100644 index 00000000000..45b1899bbf0 --- /dev/null +++ b/packages/cli/src/console.ts @@ -0,0 +1,3 @@ +export function log(message: string): void { + process.stdout.write(`${message}\n`) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d26e19b0379..409bd667745 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,6 +7,12 @@ const main = defineCommand({ version: packageJson.version, description: packageJson.description, }, + subCommands: { + async component() { + const mod = await import('./component') + return mod.component + }, + }, }) await runMain(main) From 048913529d69c7813bf02b69c786e923dd20e0ef Mon Sep 17 00:00:00 2001 From: Josh Black Date: Tue, 6 Oct 2026 16:43:20 -0500 Subject: [PATCH 03/13] feat(cli): add support for primer component get --- packages/cli/src/component.test.ts | 82 +++++++++++++++++++++++++++++- packages/cli/src/component.ts | 48 +++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/component.test.ts b/packages/cli/src/component.test.ts index a4685acb4d9..ebb72dd7102 100644 --- a/packages/cli/src/component.test.ts +++ b/packages/cli/src/component.test.ts @@ -1,6 +1,6 @@ import {runCommand} from 'citty' import componentsMetadata from '@primer/react/generated/components.json' with {type: 'json'} -import {afterEach, describe, expect, it, vi} from 'vitest' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {component} from './component' import {log} from './console' @@ -144,3 +144,83 @@ describe('component list', () => { expect(emit).not.toHaveBeenCalled() }) }) + +describe('component get', () => { + const emit = vi.mocked(log) + const fetchMock = vi.fn() + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + fetchMock.mockReset() + emit.mockClear() + }) + + it.each([ + {identifier: 'button', slug: 'button'}, + {identifier: 'Button', slug: 'button'}, + {identifier: 'bUtToN', slug: 'button'}, + {identifier: 'action_list', slug: 'action-list'}, + {identifier: 'ActionList', slug: 'action-list'}, + {identifier: 'ACTION_LIST', slug: 'action-list'}, + {identifier: 'actionbar', slug: 'action-bar'}, + {identifier: 'dialog_v2', slug: 'dialog'}, + {identifier: 'tooltip_v2', slug: 'tooltip'}, + {identifier: 'select_panel_v2', slug: 'select-panel'}, + ])('fetches documentation for $identifier from $slug', async ({identifier, slug}) => { + fetchMock.mockResolvedValue(new Response('# Component\n\nOfficial documentation.\n')) + + await runCommand(component, {rawArgs: ['get', identifier]}) + + expect(fetchMock).toHaveBeenCalledExactlyOnceWith( + new URL(`/product/components/${slug}/llms.txt`, 'https://primer.style'), + {signal: expect.any(AbortSignal)}, + ) + expect(emit).toHaveBeenCalledExactlyOnceWith('# Component\n\nOfficial documentation.') + }) + + it('rejects unknown components without fetching or emitting output', async () => { + await expect(runCommand(component, {rawArgs: ['get', 'UnknownComponent']})).rejects.toThrow( + 'No component found for "UnknownComponent". Use "primer component list" to see available components.', + ) + + expect(fetchMock).not.toHaveBeenCalled() + expect(emit).not.toHaveBeenCalled() + }) + + it('requires a component ID or name', async () => { + await expect(runCommand(component, {rawArgs: ['get']})).rejects.toThrow('Missing required positional argument') + + expect(fetchMock).not.toHaveBeenCalled() + expect(emit).not.toHaveBeenCalled() + }) + + it.each([404, 500])('reports HTTP %s without emitting output', async status => { + fetchMock.mockResolvedValue(new Response('Failed to load documentation', {status})) + + await expect(runCommand(component, {rawArgs: ['get', 'Button']})).rejects.toThrow( + `Failed to fetch documentation for Button: HTTP ${status}`, + ) + expect(emit).not.toHaveBeenCalled() + }) + + it('propagates network failures without emitting output', async () => { + const error = new TypeError('fetch failed') + fetchMock.mockRejectedValue(error) + + await expect(runCommand(component, {rawArgs: ['get', 'Button']})).rejects.toBe(error) + expect(emit).not.toHaveBeenCalled() + }) + + it('rejects empty documentation without emitting output', async () => { + fetchMock.mockResolvedValue(new Response(' \n')) + + await expect(runCommand(component, {rawArgs: ['get', 'Button']})).rejects.toThrow( + 'Documentation for Button is empty', + ) + expect(emit).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/src/component.ts b/packages/cli/src/component.ts index 43beaf8e85d..5e68b99ed3b 100644 --- a/packages/cli/src/component.ts +++ b/packages/cli/src/component.ts @@ -16,6 +16,53 @@ function parsePositiveInteger(value: string | undefined, name: string, fallback: return number } +function idToSlug(id: string): string { + if (id === 'actionbar') { + return 'action-bar' + } + + return id.replaceAll('_', '-') +} + +const get = defineCommand({ + meta: { + name: 'get', + description: 'Get official documentation for a Primer React component by ID or name', + }, + args: { + component: { + type: 'positional', + description: 'Component ID or name', + required: true, + }, + }, + async run({args}) { + const identifier = args.component.toLowerCase() + const match = Object.values(componentsMetadata.components).find(component => { + return component.id.toLowerCase() === identifier || component.name.toLowerCase() === identifier + }) + if (!match) { + throw new Error( + `No component found for "${args.component}". Use "primer component list" to see available components.`, + ) + } + + const docsId = 'docsId' in match ? match.docsId : match.id + const url = new URL(`/product/components/${idToSlug(docsId)}/llms.txt`, 'https://primer.style') + const response = await fetch(url, {signal: AbortSignal.timeout(10_000)}) + if (!response.ok) { + throw new Error(`Failed to fetch documentation for ${match.name}: HTTP ${response.status} ${response.statusText}`) + } + + const documentation = (await response.text()).trimEnd() + if (!documentation) { + throw new Error(`Documentation for ${match.name} is empty`) + } + + log(documentation) + }, +}) + const list = defineCommand({ meta: { name: 'list', @@ -80,6 +127,7 @@ export const component = defineCommand({ description: 'Explore Primer React components', }, subCommands: { + get, list, }, }) From b9b82f2d1f373d85339d10af9897a4dfd0ff73e2 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Tue, 6 Oct 2026 16:50:24 -0500 Subject: [PATCH 04/13] feat(cli): add support for primer icon list and primer icon get --- package-lock.json | 6 +- packages/cli/package.json | 6 +- packages/cli/src/component.ts | 32 +----- packages/cli/src/icon.test.ts | 195 +++++++++++++++++++++++++++++++++ packages/cli/src/icon.ts | 131 ++++++++++++++++++++++ packages/cli/src/index.ts | 4 + packages/cli/src/pagination.ts | 37 +++++++ 7 files changed, 383 insertions(+), 28 deletions(-) create mode 100644 packages/cli/src/icon.test.ts create mode 100644 packages/cli/src/icon.ts create mode 100644 packages/cli/src/pagination.ts diff --git a/package-lock.json b/package-lock.json index 7de1b2858ac..af901b67b10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30691,16 +30691,20 @@ "name": "@primer/cli", "version": "0.0.0", "dependencies": { + "cheerio": "^1.0.0", "citty": "^0.2.2", - "tablemark": "^4.1.0" + "tablemark": "^4.1.0", + "turndown": "^7.2.0" }, "bin": { "primer": "bin/primer.js" }, "devDependencies": { + "@primer/octicons": "^19.15.5", "@primer/react": "^38.40.1", "@primer/vitest-config": "^0.0.0", "@types/node": "26.0.0", + "@types/turndown": "^5.0.5", "rimraf": "5.0.5", "rolldown": "^1.2.11", "typescript": "^6.0.3", diff --git a/packages/cli/package.json b/packages/cli/package.json index e58bef9f38c..2211995f46b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -24,13 +24,17 @@ "watch": "rolldown -c -w" }, "dependencies": { + "cheerio": "^1.0.0", "citty": "^0.2.2", - "tablemark": "^4.1.0" + "tablemark": "^4.1.0", + "turndown": "^7.2.0" }, "devDependencies": { + "@primer/octicons": "^19.15.5", "@primer/react": "^38.40.1", "@primer/vitest-config": "^0.0.0", "@types/node": "26.0.0", + "@types/turndown": "^5.0.5", "rimraf": "5.0.5", "rolldown": "^1.2.11", "typescript": "^6.0.3", diff --git a/packages/cli/src/component.ts b/packages/cli/src/component.ts index 5e68b99ed3b..e0f01fe8f04 100644 --- a/packages/cli/src/component.ts +++ b/packages/cli/src/component.ts @@ -2,19 +2,7 @@ import {defineCommand} from 'citty' import {tablemark} from 'tablemark' import componentsMetadata from '@primer/react/generated/components.json' with {type: 'json'} import {log} from './console' - -function parsePositiveInteger(value: string | undefined, name: string, fallback: number): number { - if (value === undefined) { - return fallback - } - - const number = Number(value) - if (!/^[0-9]+$/.test(value) || !Number.isSafeInteger(number) || number < 1) { - throw new Error(`--${name} must be a positive safe integer`) - } - - return number -} +import {paginate} from './pagination' function idToSlug(id: string): string { if (id === 'actionbar') { @@ -89,8 +77,6 @@ const list = defineCommand({ }, }, run({args}) { - const limit = parsePositiveInteger(args.limit, 'limit', 10) - const page = parsePositiveInteger(args.page, 'page', 1) const components = Object.values(componentsMetadata.components) .map(component => { return { @@ -101,20 +87,14 @@ const list = defineCommand({ .toSorted((a, b) => { return a.name.localeCompare(b.name) }) - const paginate = args.paginate || args.limit !== undefined || args.page !== undefined - const start = (page - 1) * limit - const results = paginate ? components.slice(start, start + limit) : components - const pagination = { - page, - limit, - total: components.length, - totalPages: Math.ceil(components.length / limit), - } + const {results, pagination} = paginate(components, args) const output = args.json - ? JSON.stringify(paginate ? {components: results, pagination} : results, null, 2) + ? JSON.stringify(pagination ? {components: results, pagination} : results, null, 2) : [ results.length > 0 ? tablemark(results, {columns: ['ID', 'Name']}).trimEnd() : '| ID | Name |\n| --- | --- |', - ...(paginate ? ['', `Page ${page} of ${pagination.totalPages} (${pagination.total} components)`] : []), + ...(pagination + ? ['', `Page ${pagination.page} of ${pagination.totalPages} (${pagination.total} components)`] + : []), ].join('\n') log(output) diff --git a/packages/cli/src/icon.test.ts b/packages/cli/src/icon.test.ts new file mode 100644 index 00000000000..a628350a856 --- /dev/null +++ b/packages/cli/src/icon.test.ts @@ -0,0 +1,195 @@ +import {runCommand} from 'citty' +import octicons from '@primer/octicons/build/data.json' with {type: 'json'} +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {icon} from './icon' +import {log} from './console' + +vi.mock('./console') + +const icons = Object.values(octicons) + .map(metadata => { + return {name: metadata.name, keywords: metadata.keywords, heights: Object.keys(metadata.heights)} + }) + .toSorted((a, b) => { + return a.name.localeCompare(b.name) + }) +const emit = vi.mocked(log) +const html = String.raw + +afterEach(() => { + emit.mockClear() +}) + +describe('icon list', () => { + it('lists sorted icon names, keywords, and sizes as a Markdown table', async () => { + await runCommand(icon, {rawArgs: ['list']}) + + expect(emit).toHaveBeenCalledTimes(1) + const lines = emit.mock.calls[0][0].split('\n') + const rows = lines.map(line => { + return line + .split('|') + .slice(1, -1) + .map(cell => { + return cell.trim() + }) + }) + + expect(rows[0]).toEqual(['Name', 'Keywords', 'Sizes']) + expect(rows.slice(2)).toEqual( + icons.map(metadata => { + return [metadata.name, metadata.keywords.join(', ').trim(), metadata.heights.join(', ')] + }), + ) + }) + + it('outputs icon metadata as a JSON array without pagination', async () => { + await runCommand(icon, {rawArgs: ['list', '--json']}) + + expect(emit).toHaveBeenCalledExactlyOnceWith(JSON.stringify(icons, null, 2)) + }) + + it.each([ + {flags: ['--paginate'], page: 1, limit: 10}, + {flags: ['--limit', '3'], page: 1, limit: 3}, + {flags: ['--page', '2'], page: 2, limit: 10}, + {flags: ['--limit', '3', '--page', '2'], page: 2, limit: 3}, + { + flags: ['--limit', '3', '--page', String(Math.ceil(icons.length / 3))], + page: Math.ceil(icons.length / 3), + limit: 3, + }, + {flags: ['--page', String(icons.length + 1)], page: icons.length + 1, limit: 10}, + ])('includes pagination info with $flags', async ({flags, page, limit}) => { + await runCommand(icon, {rawArgs: ['list', '--json', ...flags]}) + + expect(emit).toHaveBeenCalledExactlyOnceWith( + JSON.stringify( + { + icons: icons.slice((page - 1) * limit, page * limit), + pagination: {page, limit, total: icons.length, totalPages: Math.ceil(icons.length / limit)}, + }, + null, + 2, + ), + ) + }) + + it('includes page info below a paginated Markdown table', async () => { + await runCommand(icon, {rawArgs: ['list', '--limit', '1', '--page', '2']}) + + expect(emit).toHaveBeenCalledTimes(1) + const output = emit.mock.calls[0][0] + expect(output).toContain(icons[1].name) + expect(output.split('\n')).toHaveLength(5) + expect(output.split('\n').slice(-2)).toEqual(['', `Page 2 of ${icons.length} (${icons.length} icons)`]) + }) + + it('retains headers and page info for an empty Markdown page', async () => { + const page = icons.length + 1 + await runCommand(icon, {rawArgs: ['list', '--page', String(page)]}) + + expect(emit).toHaveBeenCalledExactlyOnceWith( + `| Name | Keywords | Sizes |\n| --- | --- | --- |\n\nPage ${page} of ${Math.ceil(icons.length / 10)} (${icons.length} icons)`, + ) + }) + + it.each(['limit', 'page'])('rejects invalid --%s without emitting output', async flag => { + for (const value of ['0', '-1', '1.5', 'abc', '9007199254740992', '']) { + await expect(runCommand(icon, {rawArgs: ['list', `--${flag}=${value}`]})).rejects.toThrow( + `--${flag} must be a positive safe integer`, + ) + } + expect(emit).not.toHaveBeenCalled() + }) +}) + +describe('icon get', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + fetchMock.mockReset() + }) + + it.each([ + {name: 'alert', flags: [], size: '16'}, + {name: 'ALERT', flags: ['--size', '24'], size: '24'}, + ])('fetches $name at size $size and converts main content to Markdown', async ({name, flags, size}) => { + fetchMock.mockResolvedValue( + new Response( + html` +
+

Alert

+

Use AlertIcon.

+
+
Footer
`, + ), + ) + + await runCommand(icon, {rawArgs: ['get', name, ...flags]}) + + expect(fetchMock).toHaveBeenCalledExactlyOnceWith(new URL(`/octicons/icon/alert-${size}`, 'https://primer.style'), { + signal: expect.any(AbortSignal), + }) + expect(emit).toHaveBeenCalledExactlyOnceWith( + `Here is the documentation for the \`alert\` icon at size: \`${size}\`:\nAlert\n=====\n\nUse \`AlertIcon\`.`, + ) + }) + + it('requires an icon name', async () => { + await expect(runCommand(icon, {rawArgs: ['get']})).rejects.toThrow('Missing required positional argument') + + expect(fetchMock).not.toHaveBeenCalled() + expect(emit).not.toHaveBeenCalled() + }) + + it('rejects unknown icons before fetching', async () => { + await expect(runCommand(icon, {rawArgs: ['get', 'unknown-icon']})).rejects.toThrow( + 'No icon found for "unknown-icon". Use "primer icon list" to see available icons.', + ) + + expect(fetchMock).not.toHaveBeenCalled() + expect(emit).not.toHaveBeenCalled() + }) + + it.each(['32', '-1', 'abc', ''])('rejects unsupported size "%s" before fetching', async size => { + await expect(runCommand(icon, {rawArgs: ['get', 'alert', `--size=${size}`]})).rejects.toThrow( + `Size "${size}" is not available for alert. Available sizes: 16, 24`, + ) + + expect(fetchMock).not.toHaveBeenCalled() + expect(emit).not.toHaveBeenCalled() + }) + + it('reports HTTP errors without emitting output', async () => { + fetchMock.mockResolvedValue(new Response('Not found', {status: 404})) + + await expect(runCommand(icon, {rawArgs: ['get', 'alert']})).rejects.toThrow( + 'Failed to fetch documentation for alert: HTTP 404', + ) + expect(emit).not.toHaveBeenCalled() + }) + + it('propagates network errors without emitting output', async () => { + const error = new TypeError('fetch failed') + fetchMock.mockRejectedValue(error) + + await expect(runCommand(icon, {rawArgs: ['get', 'alert']})).rejects.toBe(error) + expect(emit).not.toHaveBeenCalled() + }) + + it.each(['', html`

No main content

`, html`
`, html`
`])( + 'rejects missing or empty documentation without emitting output', + async source => { + fetchMock.mockResolvedValue(new Response(source)) + + await expect(runCommand(icon, {rawArgs: ['get', 'alert']})).rejects.toThrow(/Documentation for alert is/) + expect(emit).not.toHaveBeenCalled() + }, + ) +}) diff --git a/packages/cli/src/icon.ts b/packages/cli/src/icon.ts new file mode 100644 index 00000000000..cda003ea3d1 --- /dev/null +++ b/packages/cli/src/icon.ts @@ -0,0 +1,131 @@ +import {defineCommand} from 'citty' +// eslint-disable-next-line import/no-namespace +import * as cheerio from 'cheerio' +import {tablemark} from 'tablemark' +import TurndownService from 'turndown' +import octicons from '@primer/octicons/build/data.json' with {type: 'json'} +import {log} from './console' +import {paginate} from './pagination' + +const icons = Object.values(octicons) + .map(icon => { + return { + name: icon.name, + keywords: icon.keywords, + heights: Object.keys(icon.heights), + } + }) + .toSorted((a, b) => { + return a.name.localeCompare(b.name) + }) + +const get = defineCommand({ + meta: { + name: 'get', + description: 'Get official documentation for a Primer Octicon by name', + }, + args: { + name: { + type: 'positional', + description: 'Icon name', + required: true, + }, + size: { + type: 'string', + description: 'Icon size (default: 16)', + default: '16', + valueHint: 'number', + }, + }, + async run({args}) { + const match = icons.find(icon => { + return icon.name.toLowerCase() === args.name.toLowerCase() + }) + if (!match) { + throw new Error(`No icon found for "${args.name}". Use "primer icon list" to see available icons.`) + } + if (!match.heights.includes(args.size)) { + throw new Error( + `Size "${args.size}" is not available for ${match.name}. Available sizes: ${match.heights.join(', ')}`, + ) + } + + const url = new URL(`/octicons/icon/${match.name}-${args.size}`, 'https://primer.style') + const response = await fetch(url, {signal: AbortSignal.timeout(10_000)}) + if (!response.ok) { + throw new Error(`Failed to fetch documentation for ${match.name}: HTTP ${response.status} ${response.statusText}`) + } + + const html = await response.text() + const $ = cheerio.load(html) + const source = $('main').html() + if (!source) { + throw new Error(`Documentation for ${match.name} is missing its main content`) + } + + const documentation = new TurndownService().turndown(source).trimEnd() + if (!documentation) { + throw new Error(`Documentation for ${match.name} is empty`) + } + + log(`Here is the documentation for the \`${match.name}\` icon at size: \`${args.size}\`:\n${documentation}`) + }, +}) + +const list = defineCommand({ + meta: { + name: 'list', + description: 'List the icons available from Primer Octicons React', + }, + args: { + json: { + type: 'boolean', + description: 'Output JSON instead of a Markdown table', + }, + paginate: { + type: 'boolean', + description: 'Paginate results (default: 10 icons per page, starting at page 1)', + }, + limit: { + type: 'string', + description: 'Icons per page (enables pagination)', + valueHint: 'number', + }, + page: { + type: 'string', + description: 'Page number, starting at 1 (enables pagination)', + valueHint: 'number', + }, + }, + run({args}) { + const {results, pagination} = paginate(icons, args) + const output = args.json + ? JSON.stringify(pagination ? {icons: results, pagination} : results, null, 2) + : [ + results.length > 0 + ? tablemark( + results.map(icon => { + return {name: icon.name, keywords: icon.keywords.join(', '), sizes: icon.heights.join(', ')} + }), + {columns: ['Name', 'Keywords', 'Sizes']}, + ).trimEnd() + : '| Name | Keywords | Sizes |\n| --- | --- | --- |', + ...(pagination + ? ['', `Page ${pagination.page} of ${pagination.totalPages} (${pagination.total} icons)`] + : []), + ].join('\n') + + log(output) + }, +}) + +export const icon = defineCommand({ + meta: { + name: 'icon', + description: 'Explore Primer Octicons', + }, + subCommands: { + get, + list, + }, +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 409bd667745..967b12fb176 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -12,6 +12,10 @@ const main = defineCommand({ const mod = await import('./component') return mod.component }, + async icon() { + const mod = await import('./icon') + return mod.icon + }, }, }) diff --git a/packages/cli/src/pagination.ts b/packages/cli/src/pagination.ts new file mode 100644 index 00000000000..cec23d3fa5b --- /dev/null +++ b/packages/cli/src/pagination.ts @@ -0,0 +1,37 @@ +interface PaginationArgs { + paginate?: boolean + limit?: string + page?: string +} + +function parsePositiveInteger(value: string | undefined, name: string, fallback: number): number { + if (value === undefined) { + return fallback + } + + const number = Number(value) + if (!/^[0-9]+$/.test(value) || !Number.isSafeInteger(number) || number < 1) { + throw new Error(`--${name} must be a positive safe integer`) + } + + return number +} + +export function paginate(items: readonly T[], args: PaginationArgs) { + const limit = parsePositiveInteger(args.limit, 'limit', 10) + const page = parsePositiveInteger(args.page, 'page', 1) + const enabled = args.paginate || args.limit !== undefined || args.page !== undefined + const start = (page - 1) * limit + + return { + results: enabled ? items.slice(start, start + limit) : items, + pagination: enabled + ? { + page, + limit, + total: items.length, + totalPages: Math.ceil(items.length / limit), + } + : undefined, + } +} From b689b5eb27bb2a7b1037ec7f2ba21ff005099fa2 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Tue, 6 Oct 2026 16:51:49 -0500 Subject: [PATCH 05/13] refactor(cli): move commands into separate folder --- packages/cli/src/{ => commands}/component.test.ts | 4 ++-- packages/cli/src/{ => commands}/component.ts | 4 ++-- packages/cli/src/{ => commands}/icon.test.ts | 4 ++-- packages/cli/src/{ => commands}/icon.ts | 4 ++-- packages/cli/src/index.ts | 7 ++++--- 5 files changed, 12 insertions(+), 11 deletions(-) rename packages/cli/src/{ => commands}/component.test.ts (99%) rename packages/cli/src/{ => commands}/component.ts (97%) rename packages/cli/src/{ => commands}/icon.test.ts (99%) rename packages/cli/src/{ => commands}/icon.ts (98%) diff --git a/packages/cli/src/component.test.ts b/packages/cli/src/commands/component.test.ts similarity index 99% rename from packages/cli/src/component.test.ts rename to packages/cli/src/commands/component.test.ts index ebb72dd7102..143b6330a90 100644 --- a/packages/cli/src/component.test.ts +++ b/packages/cli/src/commands/component.test.ts @@ -2,9 +2,9 @@ import {runCommand} from 'citty' import componentsMetadata from '@primer/react/generated/components.json' with {type: 'json'} import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {component} from './component' -import {log} from './console' +import {log} from '../console' -vi.mock('./console') +vi.mock('../console') const components = Object.values(componentsMetadata.components) .map(metadata => { diff --git a/packages/cli/src/component.ts b/packages/cli/src/commands/component.ts similarity index 97% rename from packages/cli/src/component.ts rename to packages/cli/src/commands/component.ts index e0f01fe8f04..9996aa8d06f 100644 --- a/packages/cli/src/component.ts +++ b/packages/cli/src/commands/component.ts @@ -1,8 +1,8 @@ import {defineCommand} from 'citty' import {tablemark} from 'tablemark' import componentsMetadata from '@primer/react/generated/components.json' with {type: 'json'} -import {log} from './console' -import {paginate} from './pagination' +import {log} from '../console' +import {paginate} from '../pagination' function idToSlug(id: string): string { if (id === 'actionbar') { diff --git a/packages/cli/src/icon.test.ts b/packages/cli/src/commands/icon.test.ts similarity index 99% rename from packages/cli/src/icon.test.ts rename to packages/cli/src/commands/icon.test.ts index a628350a856..abbdff9832a 100644 --- a/packages/cli/src/icon.test.ts +++ b/packages/cli/src/commands/icon.test.ts @@ -2,9 +2,9 @@ import {runCommand} from 'citty' import octicons from '@primer/octicons/build/data.json' with {type: 'json'} import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {icon} from './icon' -import {log} from './console' +import {log} from '../console' -vi.mock('./console') +vi.mock('../console') const icons = Object.values(octicons) .map(metadata => { diff --git a/packages/cli/src/icon.ts b/packages/cli/src/commands/icon.ts similarity index 98% rename from packages/cli/src/icon.ts rename to packages/cli/src/commands/icon.ts index cda003ea3d1..27d38fe75a6 100644 --- a/packages/cli/src/icon.ts +++ b/packages/cli/src/commands/icon.ts @@ -4,8 +4,8 @@ import * as cheerio from 'cheerio' import {tablemark} from 'tablemark' import TurndownService from 'turndown' import octicons from '@primer/octicons/build/data.json' with {type: 'json'} -import {log} from './console' -import {paginate} from './pagination' +import {log} from '../console' +import {paginate} from '../pagination' const icons = Object.values(octicons) .map(icon => { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 967b12fb176..9b4781d6b87 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,5 @@ -import {defineCommand, runMain} from 'citty' +import {runMain} from 'citty' +import {defineCommand} from 'citty' import packageJson from '../package.json' with {type: 'json'} const main = defineCommand({ @@ -9,11 +10,11 @@ const main = defineCommand({ }, subCommands: { async component() { - const mod = await import('./component') + const mod = await import('./commands/component') return mod.component }, async icon() { - const mod = await import('./icon') + const mod = await import('./commands/icon') return mod.icon }, }, From 1351c5ffb75215e3c0e66f734165bc430de21ff3 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Tue, 6 Oct 2026 18:10:04 -0500 Subject: [PATCH 06/13] feat: add support for icons and guidelines sub-commands --- packages/cli/src/commands/component.ts | 113 ------------------ .../commands/components/accessibility/get.ts | 20 ++++ .../components/accessibility/index.ts | 14 +++ .../components.test.ts} | 104 +++++++++++++++- .../src/commands/components/examples/get.ts | 27 +++++ .../src/commands/components/examples/index.ts | 14 +++ packages/cli/src/commands/components/get.ts | 32 +++++ .../commands/components/guidelines.test.ts | 107 +++++++++++++++++ .../cli/src/commands/components/guidelines.ts | 20 ++++ packages/cli/src/commands/components/index.ts | 30 +++++ packages/cli/src/commands/components/list.ts | 55 +++++++++ .../cli/src/commands/components/metadata.ts | 24 ++++ .../cli/src/commands/components/usage/get.ts | 20 ++++ .../src/commands/components/usage/index.ts | 14 +++ .../cli/src/commands/guidelines/coding/get.ts | 32 +++++ .../src/commands/guidelines/coding/index.ts | 14 +++ .../cli/src/commands/guidelines/color/get.ts | 16 +++ .../src/commands/guidelines/color/index.ts | 14 +++ .../commands/guidelines/guidelines.test.ts | 94 +++++++++++++++ packages/cli/src/commands/guidelines/index.ts | 22 ++++ .../src/commands/guidelines/typography/get.ts | 16 +++ .../commands/guidelines/typography/index.ts | 14 +++ .../commands/{icon.test.ts => icons.test.ts} | 8 +- .../cli/src/commands/{icon.ts => icons.ts} | 33 ++--- packages/cli/src/documentation.ts | 34 ++++++ packages/cli/src/index.ts | 19 +-- 26 files changed, 754 insertions(+), 156 deletions(-) delete mode 100644 packages/cli/src/commands/component.ts create mode 100644 packages/cli/src/commands/components/accessibility/get.ts create mode 100644 packages/cli/src/commands/components/accessibility/index.ts rename packages/cli/src/commands/{component.test.ts => components/components.test.ts} (68%) create mode 100644 packages/cli/src/commands/components/examples/get.ts create mode 100644 packages/cli/src/commands/components/examples/index.ts create mode 100644 packages/cli/src/commands/components/get.ts create mode 100644 packages/cli/src/commands/components/guidelines.test.ts create mode 100644 packages/cli/src/commands/components/guidelines.ts create mode 100644 packages/cli/src/commands/components/index.ts create mode 100644 packages/cli/src/commands/components/list.ts create mode 100644 packages/cli/src/commands/components/metadata.ts create mode 100644 packages/cli/src/commands/components/usage/get.ts create mode 100644 packages/cli/src/commands/components/usage/index.ts create mode 100644 packages/cli/src/commands/guidelines/coding/get.ts create mode 100644 packages/cli/src/commands/guidelines/coding/index.ts create mode 100644 packages/cli/src/commands/guidelines/color/get.ts create mode 100644 packages/cli/src/commands/guidelines/color/index.ts create mode 100644 packages/cli/src/commands/guidelines/guidelines.test.ts create mode 100644 packages/cli/src/commands/guidelines/index.ts create mode 100644 packages/cli/src/commands/guidelines/typography/get.ts create mode 100644 packages/cli/src/commands/guidelines/typography/index.ts rename packages/cli/src/commands/{icon.test.ts => icons.test.ts} (97%) rename packages/cli/src/commands/{icon.ts => icons.ts} (74%) create mode 100644 packages/cli/src/documentation.ts diff --git a/packages/cli/src/commands/component.ts b/packages/cli/src/commands/component.ts deleted file mode 100644 index 9996aa8d06f..00000000000 --- a/packages/cli/src/commands/component.ts +++ /dev/null @@ -1,113 +0,0 @@ -import {defineCommand} from 'citty' -import {tablemark} from 'tablemark' -import componentsMetadata from '@primer/react/generated/components.json' with {type: 'json'} -import {log} from '../console' -import {paginate} from '../pagination' - -function idToSlug(id: string): string { - if (id === 'actionbar') { - return 'action-bar' - } - - return id.replaceAll('_', '-') -} - -const get = defineCommand({ - meta: { - name: 'get', - description: 'Get official documentation for a Primer React component by ID or name', - }, - args: { - component: { - type: 'positional', - description: 'Component ID or name', - required: true, - }, - }, - async run({args}) { - const identifier = args.component.toLowerCase() - const match = Object.values(componentsMetadata.components).find(component => { - return component.id.toLowerCase() === identifier || component.name.toLowerCase() === identifier - }) - if (!match) { - throw new Error( - `No component found for "${args.component}". Use "primer component list" to see available components.`, - ) - } - - const docsId = 'docsId' in match ? match.docsId : match.id - const url = new URL(`/product/components/${idToSlug(docsId)}/llms.txt`, 'https://primer.style') - const response = await fetch(url, {signal: AbortSignal.timeout(10_000)}) - if (!response.ok) { - throw new Error(`Failed to fetch documentation for ${match.name}: HTTP ${response.status} ${response.statusText}`) - } - - const documentation = (await response.text()).trimEnd() - if (!documentation) { - throw new Error(`Documentation for ${match.name} is empty`) - } - - log(documentation) - }, -}) - -const list = defineCommand({ - meta: { - name: 'list', - description: 'List the components available from Primer React', - }, - args: { - json: { - type: 'boolean', - description: 'Output JSON instead of a Markdown table', - }, - paginate: { - type: 'boolean', - description: 'Paginate results (default: 10 components per page, starting at page 1)', - }, - limit: { - type: 'string', - description: 'Components per page (enables pagination)', - valueHint: 'number', - }, - page: { - type: 'string', - description: 'Page number, starting at 1 (enables pagination)', - valueHint: 'number', - }, - }, - run({args}) { - const components = Object.values(componentsMetadata.components) - .map(component => { - return { - id: component.id, - name: component.name, - } - }) - .toSorted((a, b) => { - return a.name.localeCompare(b.name) - }) - const {results, pagination} = paginate(components, args) - const output = args.json - ? JSON.stringify(pagination ? {components: results, pagination} : results, null, 2) - : [ - results.length > 0 ? tablemark(results, {columns: ['ID', 'Name']}).trimEnd() : '| ID | Name |\n| --- | --- |', - ...(pagination - ? ['', `Page ${pagination.page} of ${pagination.totalPages} (${pagination.total} components)`] - : []), - ].join('\n') - - log(output) - }, -}) - -export const component = defineCommand({ - meta: { - name: 'component', - description: 'Explore Primer React components', - }, - subCommands: { - get, - list, - }, -}) diff --git a/packages/cli/src/commands/components/accessibility/get.ts b/packages/cli/src/commands/components/accessibility/get.ts new file mode 100644 index 00000000000..5e31ad4a94c --- /dev/null +++ b/packages/cli/src/commands/components/accessibility/get.ts @@ -0,0 +1,20 @@ +import {defineCommand} from 'citty' +import {log} from '../../../console' +import {getGuidelines} from '../guidelines' + +export const get = defineCommand({ + meta: { + name: 'get', + description: 'Get accessibility guidelines for a Primer React component by ID or name', + }, + args: { + component: { + type: 'positional', + description: 'Component ID or name', + required: true, + }, + }, + async run({args}) { + log(await getGuidelines(args.component, 'accessibility')) + }, +}) diff --git a/packages/cli/src/commands/components/accessibility/index.ts b/packages/cli/src/commands/components/accessibility/index.ts new file mode 100644 index 00000000000..a55537fad0b --- /dev/null +++ b/packages/cli/src/commands/components/accessibility/index.ts @@ -0,0 +1,14 @@ +import {defineCommand} from 'citty' + +export const accessibility = defineCommand({ + meta: { + name: 'accessibility', + description: 'Explore Primer React component accessibility guidelines', + }, + subCommands: { + async get() { + const mod = await import('./get') + return mod.get + }, + }, +}) diff --git a/packages/cli/src/commands/component.test.ts b/packages/cli/src/commands/components/components.test.ts similarity index 68% rename from packages/cli/src/commands/component.test.ts rename to packages/cli/src/commands/components/components.test.ts index 143b6330a90..3c343a9e76d 100644 --- a/packages/cli/src/commands/component.test.ts +++ b/packages/cli/src/commands/components/components.test.ts @@ -1,10 +1,10 @@ import {runCommand} from 'citty' import componentsMetadata from '@primer/react/generated/components.json' with {type: 'json'} import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {component} from './component' -import {log} from '../console' +import {components as component} from './index' +import {log} from '../../console' -vi.mock('../console') +vi.mock('../../console') const components = Object.values(componentsMetadata.components) .map(metadata => { @@ -14,7 +14,7 @@ const components = Object.values(componentsMetadata.components) return a.name.localeCompare(b.name) }) -describe('component list', () => { +describe('components list', () => { const emit = vi.mocked(log) afterEach(() => { @@ -145,7 +145,7 @@ describe('component list', () => { }) }) -describe('component get', () => { +describe('components get', () => { const emit = vi.mocked(log) const fetchMock = vi.fn() @@ -184,7 +184,7 @@ describe('component get', () => { it('rejects unknown components without fetching or emitting output', async () => { await expect(runCommand(component, {rawArgs: ['get', 'UnknownComponent']})).rejects.toThrow( - 'No component found for "UnknownComponent". Use "primer component list" to see available components.', + 'No component found for "UnknownComponent". Use "primer components list" to see available components.', ) expect(fetchMock).not.toHaveBeenCalled() @@ -224,3 +224,95 @@ describe('component get', () => { expect(emit).not.toHaveBeenCalled() }) }) + +describe('components examples get', () => { + const emit = vi.mocked(log) + const fetchMock = vi.fn() + const html = String.raw + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + fetchMock.mockReset() + emit.mockClear() + }) + + it.each([ + {identifier: 'button', name: 'Button', slug: 'button'}, + {identifier: 'bUtToN', name: 'Button', slug: 'button'}, + {identifier: 'action_list', name: 'ActionList', slug: 'action-list'}, + {identifier: 'ActionList', name: 'ActionList', slug: 'action-list'}, + {identifier: 'actionbar', name: 'ActionBar', slug: 'action-bar'}, + {identifier: 'dialog_v2', name: 'Dialog', slug: 'dialog'}, + ])('gets examples for $identifier using the canonical $slug page', async ({identifier, name, slug}) => { + fetchMock.mockResolvedValue( + new Response( + html` +
+

Examples

+
<Button>Click</Button>
+
+
Footer
`, + ), + ) + + await runCommand(component, {rawArgs: ['examples', 'get', identifier]}) + + expect(fetchMock).toHaveBeenCalledExactlyOnceWith(new URL(`/product/components/${slug}`, 'https://primer.style'), { + signal: expect.any(AbortSignal), + }) + expect(emit).toHaveBeenCalledExactlyOnceWith( + `Here are some examples of how to use the \`${name}\` component from the @primer/react package:\n\nExamples\n--------\n\n `, + ) + }) + + it('requires a component ID or name', async () => { + await expect(runCommand(component, {rawArgs: ['examples', 'get']})).rejects.toThrow( + 'Missing required positional argument', + ) + + expect(fetchMock).not.toHaveBeenCalled() + expect(emit).not.toHaveBeenCalled() + }) + + it('rejects unknown components without fetching or emitting output', async () => { + await expect(runCommand(component, {rawArgs: ['examples', 'get', 'UnknownComponent']})).rejects.toThrow( + 'No component found for "UnknownComponent". Use "primer components list" to see available components.', + ) + + expect(fetchMock).not.toHaveBeenCalled() + expect(emit).not.toHaveBeenCalled() + }) + + it('reports HTTP errors without emitting output', async () => { + fetchMock.mockResolvedValue(new Response('Not found', {status: 404})) + + await expect(runCommand(component, {rawArgs: ['examples', 'get', 'Button']})).rejects.toThrow( + 'Failed to fetch documentation for Button: HTTP 404', + ) + expect(emit).not.toHaveBeenCalled() + }) + + it('propagates network errors without emitting output', async () => { + const error = new TypeError('fetch failed') + fetchMock.mockRejectedValue(error) + + await expect(runCommand(component, {rawArgs: ['examples', 'get', 'Button']})).rejects.toBe(error) + expect(emit).not.toHaveBeenCalled() + }) + + it.each(['', html`

No main content

`, html`
`, html`
`])( + 'rejects missing or empty examples without emitting output', + async source => { + fetchMock.mockResolvedValue(new Response(source)) + + await expect(runCommand(component, {rawArgs: ['examples', 'get', 'Button']})).rejects.toThrow( + /Documentation for Button is/, + ) + expect(emit).not.toHaveBeenCalled() + }, + ) +}) diff --git a/packages/cli/src/commands/components/examples/get.ts b/packages/cli/src/commands/components/examples/get.ts new file mode 100644 index 00000000000..27febc1ed09 --- /dev/null +++ b/packages/cli/src/commands/components/examples/get.ts @@ -0,0 +1,27 @@ +import {defineCommand} from 'citty' +import {log} from '../../../console' +import {fetchMarkdown} from '../../../documentation' +import {getComponent} from '../metadata' + +export const get = defineCommand({ + meta: { + name: 'get', + description: 'Get usage examples for a Primer React component by ID or name', + }, + args: { + component: { + type: 'positional', + description: 'Component ID or name', + required: true, + }, + }, + async run({args}) { + const match = getComponent(args.component) + const url = new URL(`/product/components/${match.slug}`, 'https://primer.style') + const documentation = await fetchMarkdown(url, match.name) + + log( + `Here are some examples of how to use the \`${match.name}\` component from the @primer/react package:\n\n${documentation}`, + ) + }, +}) diff --git a/packages/cli/src/commands/components/examples/index.ts b/packages/cli/src/commands/components/examples/index.ts new file mode 100644 index 00000000000..30792fbdc27 --- /dev/null +++ b/packages/cli/src/commands/components/examples/index.ts @@ -0,0 +1,14 @@ +import {defineCommand} from 'citty' + +export const examples = defineCommand({ + meta: { + name: 'examples', + description: 'Explore Primer React component usage examples', + }, + subCommands: { + async get() { + const mod = await import('./get') + return mod.get + }, + }, +}) diff --git a/packages/cli/src/commands/components/get.ts b/packages/cli/src/commands/components/get.ts new file mode 100644 index 00000000000..bccb97b7e9e --- /dev/null +++ b/packages/cli/src/commands/components/get.ts @@ -0,0 +1,32 @@ +import {defineCommand} from 'citty' +import {log} from '../../console' +import {getComponent} from './metadata' + +export const get = defineCommand({ + meta: { + name: 'get', + description: 'Get official documentation for a Primer React component by ID or name', + }, + args: { + component: { + type: 'positional', + description: 'Component ID or name', + required: true, + }, + }, + async run({args}) { + const match = getComponent(args.component) + const url = new URL(`/product/components/${match.slug}/llms.txt`, 'https://primer.style') + const response = await fetch(url, {signal: AbortSignal.timeout(10_000)}) + if (!response.ok) { + throw new Error(`Failed to fetch documentation for ${match.name}: HTTP ${response.status} ${response.statusText}`) + } + + const documentation = (await response.text()).trimEnd() + if (!documentation) { + throw new Error(`Documentation for ${match.name} is empty`) + } + + log(documentation) + }, +}) diff --git a/packages/cli/src/commands/components/guidelines.test.ts b/packages/cli/src/commands/components/guidelines.test.ts new file mode 100644 index 00000000000..4ebd34547f9 --- /dev/null +++ b/packages/cli/src/commands/components/guidelines.test.ts @@ -0,0 +1,107 @@ +import {runCommand} from 'citty' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {components as component} from './index' +import {log} from '../../console' + +vi.mock('../../console') + +describe.each([ + {kind: 'usage', path: 'guidelines'}, + {kind: 'accessibility', path: 'accessibility'}, +])('components $kind get', ({kind, path}) => { + const emit = vi.mocked(log) + const fetchMock = vi.fn() + const html = String.raw + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + fetchMock.mockReset() + emit.mockClear() + }) + + it.each([ + {identifier: 'button', name: 'Button', slug: 'button'}, + {identifier: 'bUtToN', name: 'Button', slug: 'button'}, + {identifier: 'ActionList', name: 'ActionList', slug: 'action-list'}, + {identifier: 'select_panel_v2', name: 'SelectPanel', slug: 'select-panel'}, + ])('retrieves Markdown guidelines for $identifier', async ({identifier, name, slug}) => { + fetchMock.mockResolvedValue( + new Response( + html` +
+

Guidelines

+

Use aria-label appropriately.

+
+
Footer
`, + ), + ) + + await runCommand(component, {rawArgs: [kind, 'get', identifier]}) + + expect(fetchMock).toHaveBeenCalledExactlyOnceWith( + new URL(`/product/components/${slug}/${path}`, 'https://primer.style'), + {signal: expect.any(AbortSignal)}, + ) + expect(emit).toHaveBeenCalledExactlyOnceWith( + `Here are the ${kind} guidelines for the \`${name}\` component from the @primer/react package:\n\nGuidelines\n----------\n\nUse \`aria-label\` appropriately.`, + ) + }) + + it('requires a component ID or name', async () => { + await expect(runCommand(component, {rawArgs: [kind, 'get']})).rejects.toThrow( + 'Missing required positional argument', + ) + + expect(fetchMock).not.toHaveBeenCalled() + expect(emit).not.toHaveBeenCalled() + }) + + it('rejects unknown components without fetching or emitting output', async () => { + await expect(runCommand(component, {rawArgs: [kind, 'get', 'UnknownComponent']})).rejects.toThrow( + 'No component found for "UnknownComponent". Use "primer components list" to see available components.', + ) + + expect(fetchMock).not.toHaveBeenCalled() + expect(emit).not.toHaveBeenCalled() + }) + + it('explicitly reports unavailable guidelines on HTTP 404', async () => { + fetchMock.mockResolvedValue(new Response('Not found', {status: 404})) + + await runCommand(component, {rawArgs: [kind, 'get', 'Button']}) + + expect(emit).toHaveBeenCalledExactlyOnceWith( + `There are no ${kind} guidelines for the \`Button\` component in the @primer/react package.`, + ) + }) + + it.each([302, 401, 403, 500])('fails on HTTP %s instead of reporting missing guidelines', async status => { + fetchMock.mockResolvedValue(new Response('Request failed', {status})) + + await expect(runCommand(component, {rawArgs: [kind, 'get', 'Button']})).rejects.toThrow( + `Failed to fetch documentation for Button: HTTP ${status}`, + ) + expect(emit).not.toHaveBeenCalled() + }) + + it('propagates network failures without emitting output', async () => { + const error = new TypeError('fetch failed') + fetchMock.mockRejectedValue(error) + + await expect(runCommand(component, {rawArgs: [kind, 'get', 'Button']})).rejects.toBe(error) + expect(emit).not.toHaveBeenCalled() + }) + + it('fails if the guidelines page has no main content', async () => { + fetchMock.mockResolvedValue(new Response(html`

Missing documentation

`)) + + await expect(runCommand(component, {rawArgs: [kind, 'get', 'Button']})).rejects.toThrow( + 'Documentation for Button is missing its main content', + ) + expect(emit).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/src/commands/components/guidelines.ts b/packages/cli/src/commands/components/guidelines.ts new file mode 100644 index 00000000000..17bb5f5993a --- /dev/null +++ b/packages/cli/src/commands/components/guidelines.ts @@ -0,0 +1,20 @@ +import {DocumentationFetchError, fetchMarkdown} from '../../documentation' +import {getComponent} from './metadata' + +export async function getGuidelines(identifier: string, kind: 'usage' | 'accessibility'): Promise { + const match = getComponent(identifier) + const path = kind === 'usage' ? 'guidelines' : 'accessibility' + const url = new URL(`/product/components/${match.slug}/${path}`, 'https://primer.style') + let documentation: string + + try { + documentation = await fetchMarkdown(url, match.name) + } catch (error) { + if (error instanceof DocumentationFetchError && error.status === 404) { + return `There are no ${kind} guidelines for the \`${match.name}\` component in the @primer/react package.` + } + throw error + } + + return `Here are the ${kind} guidelines for the \`${match.name}\` component from the @primer/react package:\n\n${documentation}` +} diff --git a/packages/cli/src/commands/components/index.ts b/packages/cli/src/commands/components/index.ts new file mode 100644 index 00000000000..8e9ab9051b6 --- /dev/null +++ b/packages/cli/src/commands/components/index.ts @@ -0,0 +1,30 @@ +import {defineCommand} from 'citty' + +export const components = defineCommand({ + meta: { + name: 'components', + description: 'Explore Primer React components', + }, + subCommands: { + async accessibility() { + const mod = await import('./accessibility') + return mod.accessibility + }, + async examples() { + const mod = await import('./examples') + return mod.examples + }, + async get() { + const mod = await import('./get') + return mod.get + }, + async list() { + const mod = await import('./list') + return mod.list + }, + async usage() { + const mod = await import('./usage') + return mod.usage + }, + }, +}) diff --git a/packages/cli/src/commands/components/list.ts b/packages/cli/src/commands/components/list.ts new file mode 100644 index 00000000000..c09663a9c08 --- /dev/null +++ b/packages/cli/src/commands/components/list.ts @@ -0,0 +1,55 @@ +import {defineCommand} from 'citty' +import {tablemark} from 'tablemark' +import {log} from '../../console' +import {paginate} from '../../pagination' +import {components} from './metadata' + +export const list = defineCommand({ + meta: { + name: 'list', + description: 'List the components available from Primer React', + }, + args: { + json: { + type: 'boolean', + description: 'Output JSON instead of a Markdown table', + }, + paginate: { + type: 'boolean', + description: 'Paginate results (default: 10 components per page, starting at page 1)', + }, + limit: { + type: 'string', + description: 'Components per page (enables pagination)', + valueHint: 'number', + }, + page: { + type: 'string', + description: 'Page number, starting at 1 (enables pagination)', + valueHint: 'number', + }, + }, + run({args}) { + const items = components + .map(component => { + return { + id: component.id, + name: component.name, + } + }) + .toSorted((a, b) => { + return a.name.localeCompare(b.name) + }) + const {results, pagination} = paginate(items, args) + const output = args.json + ? JSON.stringify(pagination ? {components: results, pagination} : results, null, 2) + : [ + results.length > 0 ? tablemark(results, {columns: ['ID', 'Name']}).trimEnd() : '| ID | Name |\n| --- | --- |', + ...(pagination + ? ['', `Page ${pagination.page} of ${pagination.totalPages} (${pagination.total} components)`] + : []), + ].join('\n') + + log(output) + }, +}) diff --git a/packages/cli/src/commands/components/metadata.ts b/packages/cli/src/commands/components/metadata.ts new file mode 100644 index 00000000000..264dced74bc --- /dev/null +++ b/packages/cli/src/commands/components/metadata.ts @@ -0,0 +1,24 @@ +import componentsMetadata from '@primer/react/generated/components.json' with {type: 'json'} + +export const components = Object.values(componentsMetadata.components) + +function idToSlug(id: string): string { + if (id === 'actionbar') { + return 'action-bar' + } + + return id.replaceAll('_', '-') +} + +export function getComponent(identifier: string) { + const normalized = identifier.toLowerCase() + const match = components.find(component => { + return component.id.toLowerCase() === normalized || component.name.toLowerCase() === normalized + }) + if (!match) { + throw new Error(`No component found for "${identifier}". Use "primer components list" to see available components.`) + } + + const docsId = 'docsId' in match ? match.docsId : match.id + return {name: match.name, slug: idToSlug(docsId)} +} diff --git a/packages/cli/src/commands/components/usage/get.ts b/packages/cli/src/commands/components/usage/get.ts new file mode 100644 index 00000000000..9aaa8015c2c --- /dev/null +++ b/packages/cli/src/commands/components/usage/get.ts @@ -0,0 +1,20 @@ +import {defineCommand} from 'citty' +import {log} from '../../../console' +import {getGuidelines} from '../guidelines' + +export const get = defineCommand({ + meta: { + name: 'get', + description: 'Get usage guidelines for a Primer React component by ID or name', + }, + args: { + component: { + type: 'positional', + description: 'Component ID or name', + required: true, + }, + }, + async run({args}) { + log(await getGuidelines(args.component, 'usage')) + }, +}) diff --git a/packages/cli/src/commands/components/usage/index.ts b/packages/cli/src/commands/components/usage/index.ts new file mode 100644 index 00000000000..b42224525c8 --- /dev/null +++ b/packages/cli/src/commands/components/usage/index.ts @@ -0,0 +1,14 @@ +import {defineCommand} from 'citty' + +export const usage = defineCommand({ + meta: { + name: 'usage', + description: 'Explore Primer React component usage guidelines', + }, + subCommands: { + async get() { + const mod = await import('./get') + return mod.get + }, + }, +}) diff --git a/packages/cli/src/commands/guidelines/coding/get.ts b/packages/cli/src/commands/guidelines/coding/get.ts new file mode 100644 index 00000000000..9a67464064c --- /dev/null +++ b/packages/cli/src/commands/guidelines/coding/get.ts @@ -0,0 +1,32 @@ +import {defineCommand} from 'citty' +import {log} from '../../../console' + +export const get = defineCommand({ + meta: { + name: 'get', + description: 'Get guidelines for writing code that uses Primer', + }, + run() { + log(`When writing code that uses Primer, follow these guidelines: + +## Design Tokens + +- Prefer design tokens over hard-coded values. For example, use \`var(--fgColor-default)\` instead of \`#24292f\`. Use the Primer MCP \`find_tokens\` tool to search for a design token by keyword or group, \`get_design_token_specs\` to browse available token groups, and \`get_token_group_bundle\` to retrieve all tokens within a specific group. +- Prefer design tokens in the same group for related CSS properties. For example, when styling background and border color, use tokens from the same group/category. + +## Authoring & Using Components + +- Prefer re-using a component from Primer when possible over writing a new component. +- Prefer using existing props for a component for styling instead of adding styling to a component. +- Prefer using icons from Primer instead of creating new icons. Use \`primer icons list\` to find the icon you need. +- Follow patterns from Primer when creating new components. Use the Primer MCP \`list_patterns\` tool to find the pattern you need, if one exists. +- When using a component from Primer, follow its usage and accessibility guidelines. Use \`primer components usage get \` and \`primer components accessibility get \` to retrieve them. + +## Coding guidelines + +The following list of coding guidelines must be followed: + +- Do not use the sx prop for styling components. Instead, use CSS Modules. +- Do not use the Box component for styling components. Instead, use CSS Modules.`) + }, +}) diff --git a/packages/cli/src/commands/guidelines/coding/index.ts b/packages/cli/src/commands/guidelines/coding/index.ts new file mode 100644 index 00000000000..65398a91020 --- /dev/null +++ b/packages/cli/src/commands/guidelines/coding/index.ts @@ -0,0 +1,14 @@ +import {defineCommand} from 'citty' + +export const coding = defineCommand({ + meta: { + name: 'coding', + description: 'Explore Primer coding guidelines', + }, + subCommands: { + async get() { + const mod = await import('./get') + return mod.get + }, + }, +}) diff --git a/packages/cli/src/commands/guidelines/color/get.ts b/packages/cli/src/commands/guidelines/color/get.ts new file mode 100644 index 00000000000..c1f378f81cd --- /dev/null +++ b/packages/cli/src/commands/guidelines/color/get.ts @@ -0,0 +1,16 @@ +import {defineCommand} from 'citty' +import {log} from '../../../console' +import {fetchMarkdown} from '../../../documentation' + +export const get = defineCommand({ + meta: { + name: 'get', + description: 'Get guidelines for applying color to a user interface', + }, + async run() { + const url = new URL('/product/getting-started/foundations/color-usage', 'https://primer.style') + const documentation = await fetchMarkdown(url, 'color usage') + + log(`Here is the documentation for color usage in Primer:\n\n${documentation}`) + }, +}) diff --git a/packages/cli/src/commands/guidelines/color/index.ts b/packages/cli/src/commands/guidelines/color/index.ts new file mode 100644 index 00000000000..1c67e759ded --- /dev/null +++ b/packages/cli/src/commands/guidelines/color/index.ts @@ -0,0 +1,14 @@ +import {defineCommand} from 'citty' + +export const color = defineCommand({ + meta: { + name: 'color', + description: 'Explore Primer color usage guidelines', + }, + subCommands: { + async get() { + const mod = await import('./get') + return mod.get + }, + }, +}) diff --git a/packages/cli/src/commands/guidelines/guidelines.test.ts b/packages/cli/src/commands/guidelines/guidelines.test.ts new file mode 100644 index 00000000000..aca00598636 --- /dev/null +++ b/packages/cli/src/commands/guidelines/guidelines.test.ts @@ -0,0 +1,94 @@ +import {runCommand} from 'citty' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {guidelines} from './index' +import {log} from '../../console' + +vi.mock('../../console') + +const emit = vi.mocked(log) +const fetchMock = vi.fn() +const html = String.raw + +beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() + fetchMock.mockReset() + emit.mockClear() +}) + +describe.each([ + {kind: 'color', path: 'color-usage'}, + {kind: 'typography', path: 'typography'}, +])('guidelines $kind get', ({kind, path}) => { + it('fetches the official foundation page and emits its main content as Markdown', async () => { + fetchMock.mockResolvedValue( + new Response( + html` +
+

Usage

+

Use design tokens.

+
+
Footer
`, + ), + ) + + await runCommand(guidelines, {rawArgs: [kind, 'get']}) + + expect(fetchMock).toHaveBeenCalledExactlyOnceWith( + new URL(`/product/getting-started/foundations/${path}`, 'https://primer.style'), + {signal: expect.any(AbortSignal)}, + ) + expect(emit).toHaveBeenCalledExactlyOnceWith( + `Here is the documentation for ${kind} usage in Primer:\n\nUsage\n-----\n\nUse \`design tokens\`.`, + ) + }) + + it.each([404, 500])('reports HTTP %s without emitting output', async status => { + fetchMock.mockResolvedValue(new Response('Failed to load documentation', {status})) + + await expect(runCommand(guidelines, {rawArgs: [kind, 'get']})).rejects.toThrow( + `Failed to fetch documentation for ${kind} usage: HTTP ${status}`, + ) + expect(emit).not.toHaveBeenCalled() + }) + + it('propagates network failures without emitting output', async () => { + const error = new TypeError('fetch failed') + fetchMock.mockRejectedValue(error) + + await expect(runCommand(guidelines, {rawArgs: [kind, 'get']})).rejects.toBe(error) + expect(emit).not.toHaveBeenCalled() + }) + + it.each(['', html`

No main content

`, html`
`])( + 'rejects missing or empty content without emitting output', + async source => { + fetchMock.mockResolvedValue(new Response(source)) + + await expect(runCommand(guidelines, {rawArgs: [kind, 'get']})).rejects.toThrow( + `Documentation for ${kind} usage is`, + ) + expect(emit).not.toHaveBeenCalled() + }, + ) +}) + +describe('guidelines coding get', () => { + it('emits the Primer coding guidance locally with supported CLI references', async () => { + await runCommand(guidelines, {rawArgs: ['coding', 'get']}) + + expect(fetchMock).not.toHaveBeenCalled() + expect(emit).toHaveBeenCalledTimes(1) + const output = emit.mock.calls[0][0] + expect(output).toContain('## Design Tokens') + expect(output).toContain('## Authoring & Using Components') + expect(output).toContain('Do not use the sx prop for styling components. Instead, use CSS Modules.') + expect(output).toContain('Do not use the Box component for styling components. Instead, use CSS Modules.') + expect(output).toContain('`primer icons list`') + expect(output).toContain('`primer components usage get `') + expect(output).toContain('`primer components accessibility get `') + }) +}) diff --git a/packages/cli/src/commands/guidelines/index.ts b/packages/cli/src/commands/guidelines/index.ts new file mode 100644 index 00000000000..116fbae8318 --- /dev/null +++ b/packages/cli/src/commands/guidelines/index.ts @@ -0,0 +1,22 @@ +import {defineCommand} from 'citty' + +export const guidelines = defineCommand({ + meta: { + name: 'guidelines', + description: 'Explore Primer design and coding guidelines', + }, + subCommands: { + async coding() { + const mod = await import('./coding') + return mod.coding + }, + async color() { + const mod = await import('./color') + return mod.color + }, + async typography() { + const mod = await import('./typography') + return mod.typography + }, + }, +}) diff --git a/packages/cli/src/commands/guidelines/typography/get.ts b/packages/cli/src/commands/guidelines/typography/get.ts new file mode 100644 index 00000000000..95e4363ab3e --- /dev/null +++ b/packages/cli/src/commands/guidelines/typography/get.ts @@ -0,0 +1,16 @@ +import {defineCommand} from 'citty' +import {log} from '../../../console' +import {fetchMarkdown} from '../../../documentation' + +export const get = defineCommand({ + meta: { + name: 'get', + description: 'Get guidelines for applying typography to a user interface', + }, + async run() { + const url = new URL('/product/getting-started/foundations/typography', 'https://primer.style') + const documentation = await fetchMarkdown(url, 'typography usage') + + log(`Here is the documentation for typography usage in Primer:\n\n${documentation}`) + }, +}) diff --git a/packages/cli/src/commands/guidelines/typography/index.ts b/packages/cli/src/commands/guidelines/typography/index.ts new file mode 100644 index 00000000000..42e887f07cb --- /dev/null +++ b/packages/cli/src/commands/guidelines/typography/index.ts @@ -0,0 +1,14 @@ +import {defineCommand} from 'citty' + +export const typography = defineCommand({ + meta: { + name: 'typography', + description: 'Explore Primer typography usage guidelines', + }, + subCommands: { + async get() { + const mod = await import('./get') + return mod.get + }, + }, +}) diff --git a/packages/cli/src/commands/icon.test.ts b/packages/cli/src/commands/icons.test.ts similarity index 97% rename from packages/cli/src/commands/icon.test.ts rename to packages/cli/src/commands/icons.test.ts index abbdff9832a..b0d7b06eebf 100644 --- a/packages/cli/src/commands/icon.test.ts +++ b/packages/cli/src/commands/icons.test.ts @@ -1,7 +1,7 @@ import {runCommand} from 'citty' import octicons from '@primer/octicons/build/data.json' with {type: 'json'} import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {icon} from './icon' +import {icons as icon} from './icons' import {log} from '../console' vi.mock('../console') @@ -20,7 +20,7 @@ afterEach(() => { emit.mockClear() }) -describe('icon list', () => { +describe('icons list', () => { it('lists sorted icon names, keywords, and sizes as a Markdown table', async () => { await runCommand(icon, {rawArgs: ['list']}) @@ -104,7 +104,7 @@ describe('icon list', () => { }) }) -describe('icon get', () => { +describe('icons get', () => { const fetchMock = vi.fn() beforeEach(() => { @@ -150,7 +150,7 @@ describe('icon get', () => { it('rejects unknown icons before fetching', async () => { await expect(runCommand(icon, {rawArgs: ['get', 'unknown-icon']})).rejects.toThrow( - 'No icon found for "unknown-icon". Use "primer icon list" to see available icons.', + 'No icon found for "unknown-icon". Use "primer icons list" to see available icons.', ) expect(fetchMock).not.toHaveBeenCalled() diff --git a/packages/cli/src/commands/icon.ts b/packages/cli/src/commands/icons.ts similarity index 74% rename from packages/cli/src/commands/icon.ts rename to packages/cli/src/commands/icons.ts index 27d38fe75a6..ec2501db166 100644 --- a/packages/cli/src/commands/icon.ts +++ b/packages/cli/src/commands/icons.ts @@ -1,13 +1,11 @@ import {defineCommand} from 'citty' -// eslint-disable-next-line import/no-namespace -import * as cheerio from 'cheerio' import {tablemark} from 'tablemark' -import TurndownService from 'turndown' import octicons from '@primer/octicons/build/data.json' with {type: 'json'} import {log} from '../console' +import {fetchMarkdown} from '../documentation' import {paginate} from '../pagination' -const icons = Object.values(octicons) +const availableIcons = Object.values(octicons) .map(icon => { return { name: icon.name, @@ -38,11 +36,11 @@ const get = defineCommand({ }, }, async run({args}) { - const match = icons.find(icon => { + const match = availableIcons.find(icon => { return icon.name.toLowerCase() === args.name.toLowerCase() }) if (!match) { - throw new Error(`No icon found for "${args.name}". Use "primer icon list" to see available icons.`) + throw new Error(`No icon found for "${args.name}". Use "primer icons list" to see available icons.`) } if (!match.heights.includes(args.size)) { throw new Error( @@ -51,22 +49,7 @@ const get = defineCommand({ } const url = new URL(`/octicons/icon/${match.name}-${args.size}`, 'https://primer.style') - const response = await fetch(url, {signal: AbortSignal.timeout(10_000)}) - if (!response.ok) { - throw new Error(`Failed to fetch documentation for ${match.name}: HTTP ${response.status} ${response.statusText}`) - } - - const html = await response.text() - const $ = cheerio.load(html) - const source = $('main').html() - if (!source) { - throw new Error(`Documentation for ${match.name} is missing its main content`) - } - - const documentation = new TurndownService().turndown(source).trimEnd() - if (!documentation) { - throw new Error(`Documentation for ${match.name} is empty`) - } + const documentation = await fetchMarkdown(url, match.name) log(`Here is the documentation for the \`${match.name}\` icon at size: \`${args.size}\`:\n${documentation}`) }, @@ -98,7 +81,7 @@ const list = defineCommand({ }, }, run({args}) { - const {results, pagination} = paginate(icons, args) + const {results, pagination} = paginate(availableIcons, args) const output = args.json ? JSON.stringify(pagination ? {icons: results, pagination} : results, null, 2) : [ @@ -119,9 +102,9 @@ const list = defineCommand({ }, }) -export const icon = defineCommand({ +export const icons = defineCommand({ meta: { - name: 'icon', + name: 'icons', description: 'Explore Primer Octicons', }, subCommands: { diff --git a/packages/cli/src/documentation.ts b/packages/cli/src/documentation.ts new file mode 100644 index 00000000000..f42e942fa7a --- /dev/null +++ b/packages/cli/src/documentation.ts @@ -0,0 +1,34 @@ +// eslint-disable-next-line import/no-namespace +import * as cheerio from 'cheerio' +import TurndownService from 'turndown' + +export class DocumentationFetchError extends Error { + readonly status: number + + constructor(status: number, name: string, statusText: string) { + super(`Failed to fetch documentation for ${name}: HTTP ${status} ${statusText}`) + this.name = 'DocumentationFetchError' + this.status = status + } +} + +export async function fetchMarkdown(url: URL, name: string): Promise { + const response = await fetch(url, {signal: AbortSignal.timeout(10_000)}) + if (!response.ok) { + throw new DocumentationFetchError(response.status, name, response.statusText) + } + + const html = await response.text() + const $ = cheerio.load(html) + const source = $('main').html() + if (!source) { + throw new Error(`Documentation for ${name} is missing its main content`) + } + + const documentation = new TurndownService().turndown(source).trimEnd() + if (!documentation) { + throw new Error(`Documentation for ${name} is empty`) + } + + return documentation +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9b4781d6b87..ea17b1005ec 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,4 @@ -import {runMain} from 'citty' -import {defineCommand} from 'citty' +import {defineCommand, runMain} from 'citty' import packageJson from '../package.json' with {type: 'json'} const main = defineCommand({ @@ -9,13 +8,17 @@ const main = defineCommand({ description: packageJson.description, }, subCommands: { - async component() { - const mod = await import('./commands/component') - return mod.component + async components() { + const mod = await import('./commands/components') + return mod.components }, - async icon() { - const mod = await import('./commands/icon') - return mod.icon + async guidelines() { + const mod = await import('./commands/guidelines') + return mod.guidelines + }, + async icons() { + const mod = await import('./commands/icons') + return mod.icons }, }, }) From e559d987a5ac7bd30019ff71ca58ade19517fcf6 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Tue, 6 Oct 2026 18:16:27 -0500 Subject: [PATCH 07/13] feat: add support for patterns and scenarios sub-commands --- .../cli/src/commands/guidelines/coding/get.ts | 2 +- .../commands/guidelines/guidelines.test.ts | 2 + packages/cli/src/commands/patterns/get.ts | 25 +++ packages/cli/src/commands/patterns/index.ts | 18 ++ packages/cli/src/commands/patterns/list.ts | 52 +++++ .../src/commands/patterns/patterns.test.ts | 187 ++++++++++++++++++ packages/cli/src/commands/scenarios/get.ts | 25 +++ packages/cli/src/commands/scenarios/index.ts | 18 ++ packages/cli/src/commands/scenarios/list.ts | 52 +++++ packages/cli/src/index.ts | 8 + packages/cli/src/pattern-metadata.ts | 47 +++++ 11 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/patterns/get.ts create mode 100644 packages/cli/src/commands/patterns/index.ts create mode 100644 packages/cli/src/commands/patterns/list.ts create mode 100644 packages/cli/src/commands/patterns/patterns.test.ts create mode 100644 packages/cli/src/commands/scenarios/get.ts create mode 100644 packages/cli/src/commands/scenarios/index.ts create mode 100644 packages/cli/src/commands/scenarios/list.ts create mode 100644 packages/cli/src/pattern-metadata.ts diff --git a/packages/cli/src/commands/guidelines/coding/get.ts b/packages/cli/src/commands/guidelines/coding/get.ts index 9a67464064c..9f23e5100aa 100644 --- a/packages/cli/src/commands/guidelines/coding/get.ts +++ b/packages/cli/src/commands/guidelines/coding/get.ts @@ -19,7 +19,7 @@ export const get = defineCommand({ - Prefer re-using a component from Primer when possible over writing a new component. - Prefer using existing props for a component for styling instead of adding styling to a component. - Prefer using icons from Primer instead of creating new icons. Use \`primer icons list\` to find the icon you need. -- Follow patterns from Primer when creating new components. Use the Primer MCP \`list_patterns\` tool to find the pattern you need, if one exists. +- Follow patterns from Primer when creating new components. Prefer a scenario pattern when one fits the task, and fall back to generic UI patterns otherwise. Use \`primer scenarios list\` and \`primer patterns list\` to find the pattern you need. - When using a component from Primer, follow its usage and accessibility guidelines. Use \`primer components usage get \` and \`primer components accessibility get \` to retrieve them. ## Coding guidelines diff --git a/packages/cli/src/commands/guidelines/guidelines.test.ts b/packages/cli/src/commands/guidelines/guidelines.test.ts index aca00598636..ccfa29728c2 100644 --- a/packages/cli/src/commands/guidelines/guidelines.test.ts +++ b/packages/cli/src/commands/guidelines/guidelines.test.ts @@ -88,6 +88,8 @@ describe('guidelines coding get', () => { expect(output).toContain('Do not use the sx prop for styling components. Instead, use CSS Modules.') expect(output).toContain('Do not use the Box component for styling components. Instead, use CSS Modules.') expect(output).toContain('`primer icons list`') + expect(output).toContain('`primer scenarios list`') + expect(output).toContain('`primer patterns list`') expect(output).toContain('`primer components usage get `') expect(output).toContain('`primer components accessibility get `') }) diff --git a/packages/cli/src/commands/patterns/get.ts b/packages/cli/src/commands/patterns/get.ts new file mode 100644 index 00000000000..e64f1366320 --- /dev/null +++ b/packages/cli/src/commands/patterns/get.ts @@ -0,0 +1,25 @@ +import {defineCommand} from 'citty' +import {log} from '../../console' +import {fetchMarkdown} from '../../documentation' +import {getPattern} from '../../pattern-metadata' + +export const get = defineCommand({ + meta: { + name: 'get', + description: 'Get a Primer UI pattern by ID or name', + }, + args: { + pattern: { + type: 'positional', + description: 'UI pattern ID or name', + required: true, + }, + }, + async run({args}) { + const match = getPattern(args.pattern, 'patterns') + const url = new URL(`/product/ui-patterns/${match.id}`, 'https://primer.style') + const documentation = await fetchMarkdown(url, match.name) + + log(`Here are the guidelines for the \`${match.name}\` pattern for Primer:\n\n${documentation}`) + }, +}) diff --git a/packages/cli/src/commands/patterns/index.ts b/packages/cli/src/commands/patterns/index.ts new file mode 100644 index 00000000000..f3e214af1b6 --- /dev/null +++ b/packages/cli/src/commands/patterns/index.ts @@ -0,0 +1,18 @@ +import {defineCommand} from 'citty' + +export const patterns = defineCommand({ + meta: { + name: 'patterns', + description: 'Explore generic Primer UI patterns (prefer scenarios when they fit the task)', + }, + subCommands: { + async get() { + const mod = await import('./get') + return mod.get + }, + async list() { + const mod = await import('./list') + return mod.list + }, + }, +}) diff --git a/packages/cli/src/commands/patterns/list.ts b/packages/cli/src/commands/patterns/list.ts new file mode 100644 index 00000000000..f5b309b396c --- /dev/null +++ b/packages/cli/src/commands/patterns/list.ts @@ -0,0 +1,52 @@ +import {defineCommand} from 'citty' +import {tablemark} from 'tablemark' +import {log} from '../../console' +import {paginate} from '../../pagination' +import {patterns} from '../../pattern-metadata' + +export const list = defineCommand({ + meta: { + name: 'list', + description: 'List generic Primer UI patterns', + }, + args: { + json: { + type: 'boolean', + description: 'Output JSON instead of a Markdown table', + }, + paginate: { + type: 'boolean', + description: 'Paginate results (default: 10 patterns per page, starting at page 1)', + }, + limit: { + type: 'string', + description: 'Patterns per page (enables pagination)', + valueHint: 'number', + }, + page: { + type: 'string', + description: 'Page number, starting at 1 (enables pagination)', + valueHint: 'number', + }, + }, + run({args}) { + const {results, pagination} = paginate(patterns, args) + const output = args.json + ? JSON.stringify(pagination ? {patterns: results, pagination} : results, null, 2) + : [ + results.length > 0 + ? tablemark( + results.map(pattern => { + return {id: pattern.id, name: pattern.name} + }), + {columns: ['ID', 'Name']}, + ).trimEnd() + : '| ID | Name |\n| --- | --- |', + ...(pagination + ? ['', `Page ${pagination.page} of ${pagination.totalPages} (${pagination.total} patterns)`] + : []), + ].join('\n') + + log(output) + }, +}) diff --git a/packages/cli/src/commands/patterns/patterns.test.ts b/packages/cli/src/commands/patterns/patterns.test.ts new file mode 100644 index 00000000000..4c78c443303 --- /dev/null +++ b/packages/cli/src/commands/patterns/patterns.test.ts @@ -0,0 +1,187 @@ +import {runCommand} from 'citty' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {patterns as patternsCommand} from './index' +import {scenarios as scenariosCommand} from '../scenarios' +import {patterns, scenarios} from '../../pattern-metadata' +import {log} from '../../console' + +vi.mock('../../console') + +describe.each([ + { + command: patternsCommand, + kind: 'patterns', + label: 'pattern', + path: 'ui-patterns', + entries: patterns, + identifiers: ['data-visualization', 'Data Visualization', 'DATA VISUALIZATION'], + name: 'Data Visualization', + id: 'data-visualization', + wrongCategory: 'create', + }, + { + command: scenariosCommand, + kind: 'scenarios', + label: 'scenario', + path: 'scenario-patterns', + entries: scenarios, + identifiers: ['create', 'Create', 'CREATE'], + name: 'Create', + id: 'create', + wrongCategory: 'data-visualization', + }, +])('$kind commands', ({command, kind, label, path, entries, identifiers, name, id, wrongCategory}) => { + const emit = vi.mocked(log) + const fetchMock = vi.fn() + const html = String.raw + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + fetchMock.mockReset() + emit.mockClear() + }) + + it('lists only its own catalog in an aligned Markdown table without fetching', async () => { + await runCommand(command, {rawArgs: ['list']}) + + expect(fetchMock).not.toHaveBeenCalled() + expect(emit).toHaveBeenCalledTimes(1) + const rows = emit.mock.calls[0][0].split('\n').map(line => { + return line + .split('|') + .slice(1, -1) + .map(cell => { + return cell.trim() + }) + }) + expect(rows[0]).toEqual(['ID', 'Name']) + expect(rows.slice(2)).toEqual( + entries.map(entry => { + return [entry.id, entry.name] + }), + ) + }) + + it('lists IDs and names as a JSON array without pagination', async () => { + await runCommand(command, {rawArgs: ['list', '--json']}) + + expect(emit).toHaveBeenCalledExactlyOnceWith(JSON.stringify(entries, null, 2)) + }) + + it.each([ + {flags: ['--paginate'], page: 1, limit: 10}, + {flags: ['--limit', '3'], page: 1, limit: 3}, + {flags: ['--page', '2'], page: 2, limit: 10}, + {flags: ['--limit', '3', '--page', '2'], page: 2, limit: 3}, + { + flags: ['--limit', '4', '--page', String(Math.ceil(entries.length / 4))], + page: Math.ceil(entries.length / 4), + limit: 4, + }, + ])('includes the correct JSON envelope and page info for $flags', async ({flags, page, limit}) => { + await runCommand(command, {rawArgs: ['list', '--json', ...flags]}) + + expect(emit).toHaveBeenCalledExactlyOnceWith( + JSON.stringify( + { + [kind]: entries.slice((page - 1) * limit, page * limit), + pagination: {page, limit, total: entries.length, totalPages: Math.ceil(entries.length / limit)}, + }, + null, + 2, + ), + ) + }) + + it('includes a page summary below paginated Markdown', async () => { + await runCommand(command, {rawArgs: ['list', '--limit', '1', '--page', '2']}) + + expect(emit).toHaveBeenCalledTimes(1) + const lines = emit.mock.calls[0][0].split('\n') + expect(lines).toHaveLength(5) + expect(lines[2]).toContain(entries[1].id) + expect(lines.slice(-2)).toEqual(['', `Page 2 of ${entries.length} (${entries.length} ${kind})`]) + }) + + it('retains Markdown headers and page info for an empty page', async () => { + await runCommand(command, {rawArgs: ['list', '--page', '2']}) + + expect(emit).toHaveBeenCalledExactlyOnceWith( + `| ID | Name |\n| --- | --- |\n\nPage 2 of 1 (${entries.length} ${kind})`, + ) + }) + + it.each(['limit', 'page'])('rejects invalid --%s without emitting output', async flag => { + await expect(runCommand(command, {rawArgs: ['list', `--${flag}`, '0']})).rejects.toThrow( + `--${flag} must be a positive safe integer`, + ) + expect(emit).not.toHaveBeenCalled() + }) + + it.each(identifiers)('gets "%s" from its own documentation route', async identifier => { + fetchMock.mockResolvedValue( + new Response( + html` +
+

Guidelines

+

Use Primer.

+
+
Footer
`, + ), + ) + + await runCommand(command, {rawArgs: ['get', identifier]}) + + expect(fetchMock).toHaveBeenCalledExactlyOnceWith(new URL(`/product/${path}/${id}`, 'https://primer.style'), { + signal: expect.any(AbortSignal), + }) + expect(emit).toHaveBeenCalledExactlyOnceWith( + `Here are the guidelines for the \`${name}\` ${label} for Primer:\n\nGuidelines\n----------\n\nUse **Primer**.`, + ) + }) + + it('requires an ID or name', async () => { + await expect(runCommand(command, {rawArgs: ['get']})).rejects.toThrow('Missing required positional argument') + + expect(fetchMock).not.toHaveBeenCalled() + expect(emit).not.toHaveBeenCalled() + }) + + it.each(['unknown', wrongCategory])('rejects "%s" instead of searching the other category', async identifier => { + await expect(runCommand(command, {rawArgs: ['get', identifier]})).rejects.toThrow( + `No ${label} found for "${identifier}". Use "primer ${kind} list" to see available ${kind}.`, + ) + expect(fetchMock).not.toHaveBeenCalled() + expect(emit).not.toHaveBeenCalled() + }) + + it.each([404, 500])('reports HTTP %s without emitting output', async status => { + fetchMock.mockResolvedValue(new Response('Request failed', {status})) + + await expect(runCommand(command, {rawArgs: ['get', id]})).rejects.toThrow( + `Failed to fetch documentation for ${name}: HTTP ${status}`, + ) + expect(emit).not.toHaveBeenCalled() + }) + + it('propagates network errors without emitting output', async () => { + const error = new TypeError('fetch failed') + fetchMock.mockRejectedValue(error) + + await expect(runCommand(command, {rawArgs: ['get', id]})).rejects.toBe(error) + expect(emit).not.toHaveBeenCalled() + }) + + it('rejects an empty documentation page without emitting output', async () => { + fetchMock.mockResolvedValue(new Response(html`
`)) + + await expect(runCommand(command, {rawArgs: ['get', id]})).rejects.toThrow( + `Documentation for ${name} is missing its main content`, + ) + expect(emit).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/src/commands/scenarios/get.ts b/packages/cli/src/commands/scenarios/get.ts new file mode 100644 index 00000000000..b80e296390a --- /dev/null +++ b/packages/cli/src/commands/scenarios/get.ts @@ -0,0 +1,25 @@ +import {defineCommand} from 'citty' +import {log} from '../../console' +import {fetchMarkdown} from '../../documentation' +import {getPattern} from '../../pattern-metadata' + +export const get = defineCommand({ + meta: { + name: 'get', + description: 'Get a Primer scenario pattern by ID or name', + }, + args: { + scenario: { + type: 'positional', + description: 'Scenario ID or name', + required: true, + }, + }, + async run({args}) { + const match = getPattern(args.scenario, 'scenarios') + const url = new URL(`/product/scenario-patterns/${match.id}`, 'https://primer.style') + const documentation = await fetchMarkdown(url, match.name) + + log(`Here are the guidelines for the \`${match.name}\` scenario for Primer:\n\n${documentation}`) + }, +}) diff --git a/packages/cli/src/commands/scenarios/index.ts b/packages/cli/src/commands/scenarios/index.ts new file mode 100644 index 00000000000..8039d5a5114 --- /dev/null +++ b/packages/cli/src/commands/scenarios/index.ts @@ -0,0 +1,18 @@ +import {defineCommand} from 'citty' + +export const scenarios = defineCommand({ + meta: { + name: 'scenarios', + description: 'Explore Primer scenario patterns for specific user tasks', + }, + subCommands: { + async get() { + const mod = await import('./get') + return mod.get + }, + async list() { + const mod = await import('./list') + return mod.list + }, + }, +}) diff --git a/packages/cli/src/commands/scenarios/list.ts b/packages/cli/src/commands/scenarios/list.ts new file mode 100644 index 00000000000..8f2f89fa813 --- /dev/null +++ b/packages/cli/src/commands/scenarios/list.ts @@ -0,0 +1,52 @@ +import {defineCommand} from 'citty' +import {tablemark} from 'tablemark' +import {log} from '../../console' +import {paginate} from '../../pagination' +import {scenarios} from '../../pattern-metadata' + +export const list = defineCommand({ + meta: { + name: 'list', + description: 'List Primer scenario patterns for specific user tasks', + }, + args: { + json: { + type: 'boolean', + description: 'Output JSON instead of a Markdown table', + }, + paginate: { + type: 'boolean', + description: 'Paginate results (default: 10 scenarios per page, starting at page 1)', + }, + limit: { + type: 'string', + description: 'Scenarios per page (enables pagination)', + valueHint: 'number', + }, + page: { + type: 'string', + description: 'Page number, starting at 1 (enables pagination)', + valueHint: 'number', + }, + }, + run({args}) { + const {results, pagination} = paginate(scenarios, args) + const output = args.json + ? JSON.stringify(pagination ? {scenarios: results, pagination} : results, null, 2) + : [ + results.length > 0 + ? tablemark( + results.map(scenario => { + return {id: scenario.id, name: scenario.name} + }), + {columns: ['ID', 'Name']}, + ).trimEnd() + : '| ID | Name |\n| --- | --- |', + ...(pagination + ? ['', `Page ${pagination.page} of ${pagination.totalPages} (${pagination.total} scenarios)`] + : []), + ].join('\n') + + log(output) + }, +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ea17b1005ec..2ef2bcacbc7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -20,6 +20,14 @@ const main = defineCommand({ const mod = await import('./commands/icons') return mod.icons }, + async patterns() { + const mod = await import('./commands/patterns') + return mod.patterns + }, + async scenarios() { + const mod = await import('./commands/scenarios') + return mod.scenarios + }, }, }) diff --git a/packages/cli/src/pattern-metadata.ts b/packages/cli/src/pattern-metadata.ts new file mode 100644 index 00000000000..0e0e518b9c2 --- /dev/null +++ b/packages/cli/src/pattern-metadata.ts @@ -0,0 +1,47 @@ +export interface Pattern { + id: string + name: string +} + +export const patterns: readonly Pattern[] = [ + {id: 'data-visualization', name: 'Data Visualization'}, + {id: 'degraded-experiences', name: 'Degraded Experiences'}, + {id: 'empty-states', name: 'Empty States'}, + {id: 'feature-onboarding', name: 'Feature Onboarding'}, + {id: 'forms', name: 'Forms'}, + {id: 'loading', name: 'Loading'}, + {id: 'navigation', name: 'Navigation'}, + {id: 'notification-messaging', name: 'Notification message'}, + {id: 'progressive-disclosure', name: 'Progressive disclosure'}, + {id: 'saving', name: 'Saving'}, +].toSorted((a, b) => { + return a.name.localeCompare(b.name) +}) + +export const scenarios: readonly Pattern[] = [ + {id: 'copy', name: 'Copy'}, + {id: 'create', name: 'Create'}, + {id: 'customize', name: 'Customize'}, + {id: 'delegate', name: 'Delegate'}, + {id: 'delete', name: 'Delete'}, + {id: 'edit', name: 'Edit'}, + {id: 'filter', name: 'Filter'}, + {id: 'search', name: 'Search'}, + {id: 'view', name: 'View'}, +].toSorted((a, b) => { + return a.name.localeCompare(b.name) +}) + +export function getPattern(identifier: string, kind: 'patterns' | 'scenarios'): Pattern { + const entries = kind === 'patterns' ? patterns : scenarios + const normalized = identifier.toLowerCase() + const match = entries.find(pattern => { + return pattern.id.toLowerCase() === normalized || pattern.name.toLowerCase() === normalized + }) + if (!match) { + const label = kind === 'patterns' ? 'pattern' : 'scenario' + throw new Error(`No ${label} found for "${identifier}". Use "primer ${kind} list" to see available ${kind}.`) + } + + return match +} From e700c5c985d4ee6aba8959de81bc7f61bfac719e Mon Sep 17 00:00:00 2001 From: Josh Black Date: Tue, 6 Oct 2026 19:14:06 -0500 Subject: [PATCH 08/13] feat: add support for primer tokens --- package-lock.json | 1 + packages/cli/package.json | 1 + .../cli/src/commands/guidelines/coding/get.ts | 2 +- .../commands/guidelines/guidelines.test.ts | 6 + packages/cli/src/commands/patterns/get.ts | 4 +- .../src/commands/patterns/patterns.test.ts | 46 ++- packages/cli/src/commands/scenarios/get.ts | 4 +- packages/cli/src/commands/tokens/get.ts | 20 + .../cli/src/commands/tokens/group/index.ts | 11 + .../cli/src/commands/tokens/group/list.ts | 29 ++ .../cli/src/commands/tokens/guidelines.ts | 140 +++++++ packages/cli/src/commands/tokens/index.ts | 34 ++ packages/cli/src/commands/tokens/list.ts | 15 + packages/cli/src/commands/tokens/output.ts | 75 ++++ packages/cli/src/commands/tokens/search.ts | 25 ++ packages/cli/src/commands/tokens/specs.ts | 10 + .../cli/src/commands/tokens/tokens.test.ts | 324 ++++++++++++++++ packages/cli/src/commands/tokens/usage/get.ts | 10 + .../cli/src/commands/tokens/usage/index.ts | 11 + packages/cli/src/documentation.ts | 19 + packages/cli/src/index.ts | 4 + packages/cli/src/token-metadata.ts | 349 ++++++++++++++++++ 22 files changed, 1126 insertions(+), 14 deletions(-) create mode 100644 packages/cli/src/commands/tokens/get.ts create mode 100644 packages/cli/src/commands/tokens/group/index.ts create mode 100644 packages/cli/src/commands/tokens/group/list.ts create mode 100644 packages/cli/src/commands/tokens/guidelines.ts create mode 100644 packages/cli/src/commands/tokens/index.ts create mode 100644 packages/cli/src/commands/tokens/list.ts create mode 100644 packages/cli/src/commands/tokens/output.ts create mode 100644 packages/cli/src/commands/tokens/search.ts create mode 100644 packages/cli/src/commands/tokens/specs.ts create mode 100644 packages/cli/src/commands/tokens/tokens.test.ts create mode 100644 packages/cli/src/commands/tokens/usage/get.ts create mode 100644 packages/cli/src/commands/tokens/usage/index.ts create mode 100644 packages/cli/src/token-metadata.ts diff --git a/package-lock.json b/package-lock.json index af901b67b10..7201daa9f79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30691,6 +30691,7 @@ "name": "@primer/cli", "version": "0.0.0", "dependencies": { + "@primer/primitives": "^11.5.1", "cheerio": "^1.0.0", "citty": "^0.2.2", "tablemark": "^4.1.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 2211995f46b..176aeb2a3b8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -24,6 +24,7 @@ "watch": "rolldown -c -w" }, "dependencies": { + "@primer/primitives": "^11.5.1", "cheerio": "^1.0.0", "citty": "^0.2.2", "tablemark": "^4.1.0", diff --git a/packages/cli/src/commands/guidelines/coding/get.ts b/packages/cli/src/commands/guidelines/coding/get.ts index 9f23e5100aa..85a456c26e9 100644 --- a/packages/cli/src/commands/guidelines/coding/get.ts +++ b/packages/cli/src/commands/guidelines/coding/get.ts @@ -11,7 +11,7 @@ export const get = defineCommand({ ## Design Tokens -- Prefer design tokens over hard-coded values. For example, use \`var(--fgColor-default)\` instead of \`#24292f\`. Use the Primer MCP \`find_tokens\` tool to search for a design token by keyword or group, \`get_design_token_specs\` to browse available token groups, and \`get_token_group_bundle\` to retrieve all tokens within a specific group. +- Prefer design tokens over hard-coded values. For example, use \`var(--fgColor-default)\` instead of \`#24292f\`. Use \`primer tokens search\` to search by keyword or group, \`primer tokens specs\` to browse available token groups, and \`primer tokens group list \` to retrieve related token groups. Use \`primer tokens get \` for a token's value and guidance, and \`primer tokens usage get\` for reference examples. - Prefer design tokens in the same group for related CSS properties. For example, when styling background and border color, use tokens from the same group/category. ## Authoring & Using Components diff --git a/packages/cli/src/commands/guidelines/guidelines.test.ts b/packages/cli/src/commands/guidelines/guidelines.test.ts index ccfa29728c2..e7644f2f8ac 100644 --- a/packages/cli/src/commands/guidelines/guidelines.test.ts +++ b/packages/cli/src/commands/guidelines/guidelines.test.ts @@ -92,5 +92,11 @@ describe('guidelines coding get', () => { expect(output).toContain('`primer patterns list`') expect(output).toContain('`primer components usage get `') expect(output).toContain('`primer components accessibility get `') + expect(output).toContain('`primer tokens search`') + expect(output).toContain('`primer tokens specs`') + expect(output).toContain('`primer tokens group list `') + expect(output).toContain('`primer tokens get `') + expect(output).toContain('`primer tokens usage get`') + expect(output).not.toContain('find_tokens') }) }) diff --git a/packages/cli/src/commands/patterns/get.ts b/packages/cli/src/commands/patterns/get.ts index e64f1366320..21303f4e601 100644 --- a/packages/cli/src/commands/patterns/get.ts +++ b/packages/cli/src/commands/patterns/get.ts @@ -1,6 +1,6 @@ import {defineCommand} from 'citty' import {log} from '../../console' -import {fetchMarkdown} from '../../documentation' +import {fetchPreferredMarkdown} from '../../documentation' import {getPattern} from '../../pattern-metadata' export const get = defineCommand({ @@ -18,7 +18,7 @@ export const get = defineCommand({ async run({args}) { const match = getPattern(args.pattern, 'patterns') const url = new URL(`/product/ui-patterns/${match.id}`, 'https://primer.style') - const documentation = await fetchMarkdown(url, match.name) + const documentation = await fetchPreferredMarkdown(url, match.name) log(`Here are the guidelines for the \`${match.name}\` pattern for Primer:\n\n${documentation}`) }, diff --git a/packages/cli/src/commands/patterns/patterns.test.ts b/packages/cli/src/commands/patterns/patterns.test.ts index 4c78c443303..c95cea399c4 100644 --- a/packages/cli/src/commands/patterns/patterns.test.ts +++ b/packages/cli/src/commands/patterns/patterns.test.ts @@ -123,7 +123,21 @@ describe.each([ }) it.each(identifiers)('gets "%s" from its own documentation route', async identifier => { - fetchMock.mockResolvedValue( + fetchMock.mockResolvedValue(new Response('Guidelines\n----------\n\nUse **Primer**.\n')) + + await runCommand(command, {rawArgs: ['get', identifier]}) + + expect(fetchMock).toHaveBeenCalledExactlyOnceWith( + new URL(`/product/${path}/${id}/llms.txt`, 'https://primer.style'), + {signal: expect.any(AbortSignal)}, + ) + expect(emit).toHaveBeenCalledExactlyOnceWith( + `Here are the guidelines for the \`${name}\` ${label} for Primer:\n\nGuidelines\n----------\n\nUse **Primer**.`, + ) + }) + + it('falls back to HTML only when the llms.txt endpoint is missing', async () => { + fetchMock.mockResolvedValueOnce(new Response('Not found', {status: 404})).mockResolvedValueOnce( new Response( html`
@@ -134,9 +148,13 @@ describe.each([ ), ) - await runCommand(command, {rawArgs: ['get', identifier]}) + await runCommand(command, {rawArgs: ['get', id]}) - expect(fetchMock).toHaveBeenCalledExactlyOnceWith(new URL(`/product/${path}/${id}`, 'https://primer.style'), { + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenNthCalledWith(1, new URL(`/product/${path}/${id}/llms.txt`, 'https://primer.style'), { + signal: expect.any(AbortSignal), + }) + expect(fetchMock).toHaveBeenNthCalledWith(2, new URL(`/product/${path}/${id}`, 'https://primer.style'), { signal: expect.any(AbortSignal), }) expect(emit).toHaveBeenCalledExactlyOnceWith( @@ -159,12 +177,23 @@ describe.each([ expect(emit).not.toHaveBeenCalled() }) - it.each([404, 500])('reports HTTP %s without emitting output', async status => { + it.each([401, 403, 500])('reports HTTP %s without falling back or emitting output', async status => { fetchMock.mockResolvedValue(new Response('Request failed', {status})) await expect(runCommand(command, {rawArgs: ['get', id]})).rejects.toThrow( `Failed to fetch documentation for ${name}: HTTP ${status}`, ) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(emit).not.toHaveBeenCalled() + }) + + it('reports an unavailable HTML fallback rather than emitting output', async () => { + fetchMock.mockResolvedValue(new Response('Not found', {status: 404})) + + await expect(runCommand(command, {rawArgs: ['get', id]})).rejects.toThrow( + `Failed to fetch documentation for ${name}: HTTP 404`, + ) + expect(fetchMock).toHaveBeenCalledTimes(2) expect(emit).not.toHaveBeenCalled() }) @@ -176,12 +205,11 @@ describe.each([ expect(emit).not.toHaveBeenCalled() }) - it('rejects an empty documentation page without emitting output', async () => { - fetchMock.mockResolvedValue(new Response(html`
`)) + it('rejects empty llms.txt content without falling back or emitting output', async () => { + fetchMock.mockResolvedValue(new Response(' \n')) - await expect(runCommand(command, {rawArgs: ['get', id]})).rejects.toThrow( - `Documentation for ${name} is missing its main content`, - ) + await expect(runCommand(command, {rawArgs: ['get', id]})).rejects.toThrow(`Documentation for ${name} is empty`) + expect(fetchMock).toHaveBeenCalledTimes(1) expect(emit).not.toHaveBeenCalled() }) }) diff --git a/packages/cli/src/commands/scenarios/get.ts b/packages/cli/src/commands/scenarios/get.ts index b80e296390a..a9322b32cda 100644 --- a/packages/cli/src/commands/scenarios/get.ts +++ b/packages/cli/src/commands/scenarios/get.ts @@ -1,6 +1,6 @@ import {defineCommand} from 'citty' import {log} from '../../console' -import {fetchMarkdown} from '../../documentation' +import {fetchPreferredMarkdown} from '../../documentation' import {getPattern} from '../../pattern-metadata' export const get = defineCommand({ @@ -18,7 +18,7 @@ export const get = defineCommand({ async run({args}) { const match = getPattern(args.scenario, 'scenarios') const url = new URL(`/product/scenario-patterns/${match.id}`, 'https://primer.style') - const documentation = await fetchMarkdown(url, match.name) + const documentation = await fetchPreferredMarkdown(url, match.name) log(`Here are the guidelines for the \`${match.name}\` scenario for Primer:\n\n${documentation}`) }, diff --git a/packages/cli/src/commands/tokens/get.ts b/packages/cli/src/commands/tokens/get.ts new file mode 100644 index 00000000000..850404c2c38 --- /dev/null +++ b/packages/cli/src/commands/tokens/get.ts @@ -0,0 +1,20 @@ +import {defineCommand} from 'citty' +import {log} from '../../console' +import {getToken} from '../../token-metadata' +import {formatToken} from './output' + +export const get = defineCommand({ + meta: {name: 'get', description: 'Get a token value and usage guidelines by name'}, + args: { + name: { + type: 'positional', + description: 'Token name, CSS custom property, or var() reference', + required: true, + }, + json: {type: 'boolean', description: 'Output JSON instead of Markdown'}, + }, + run({args}) { + const token = getToken(args.name) + log(args.json ? JSON.stringify(token, null, 2) : formatToken(token)) + }, +}) diff --git a/packages/cli/src/commands/tokens/group/index.ts b/packages/cli/src/commands/tokens/group/index.ts new file mode 100644 index 00000000000..bcc988ed067 --- /dev/null +++ b/packages/cli/src/commands/tokens/group/index.ts @@ -0,0 +1,11 @@ +import {defineCommand} from 'citty' + +export const group = defineCommand({ + meta: {name: 'group', description: 'Retrieve bundles of related design token groups'}, + subCommands: { + async list() { + const mod = await import('./list') + return mod.list + }, + }, +}) diff --git a/packages/cli/src/commands/tokens/group/list.ts b/packages/cli/src/commands/tokens/group/list.ts new file mode 100644 index 00000000000..14f8aa8ad75 --- /dev/null +++ b/packages/cli/src/commands/tokens/group/list.ts @@ -0,0 +1,29 @@ +import {defineCommand} from 'citty' +import {log} from '../../../console' +import {paginate} from '../../../pagination' +import {getGroupTokens, groupHints, resolveGroup} from '../../../token-metadata' +import {formatTokens, listArgs} from '../output' + +export const list = defineCommand({ + meta: {name: 'list', description: 'Get tokens for one or more groups, including usage guidance'}, + args: { + ...listArgs, + groups: {type: 'positional', description: 'One or more group names or aliases', required: true}, + }, + run({args}) { + const groups = Array.from(new Set(args._.map(resolveGroup))) + const matches = getGroupTokens(groups) + const hints = groups + .map(name => { + return groupHints.get(name) + }) + .filter(Boolean) + if (args.json) { + const {results, pagination} = paginate(matches, args) + log(JSON.stringify({tokens: results, groups, hints, ...(pagination ? {pagination} : {})}, null, 2)) + return + } + const output = formatTokens(matches, args, true) + log([output, ...(hints.length > 0 ? ['\n## Usage Guidance\n', ...hints] : [])].join('\n')) + }, +}) diff --git a/packages/cli/src/commands/tokens/guidelines.ts b/packages/cli/src/commands/tokens/guidelines.ts new file mode 100644 index 00000000000..49faa915932 --- /dev/null +++ b/packages/cli/src/commands/tokens/guidelines.ts @@ -0,0 +1,140 @@ +import {tablemark} from 'tablemark' +import {listTokenGroups, loadTokenGuide} from '../../token-metadata' + +export function getTokenSpecs(): string { + return `# Design Token Specifications + +## Core Rules + +- Use design tokens instead of raw colors, dimensions, durations, or easing values. +- Use typography shorthand tokens where available. Otherwise use individual font-size, font-family, and line-height tokens. +- Define rest, hover, focus-visible, active, and disabled states for interactive elements. +- Apply transitions to the base class and respect \`prefers-reduced-motion\`. +- Use \`outline: var(--focus-outline)\` and \`outline-offset: var(--outline-focus-offset)\` for focus-visible. +- Validate CSS with the project's linting tools. The Primer MCP server also provides \`lint_css\`. + +## Typography Constraints + +Do not add size suffixes to \`caption\`, \`display\`, \`codeBlock\`, or \`codeInline\` shorthands. Fetch an existing token with \`primer tokens get\` instead of inventing a name. + +## Color and Semantic Mapping + +| Color or intent | Semantic role | Background | Foreground | +| --- | --- | --- | --- | +| Blue / Interactive | accent | emphasis | fgColor-onEmphasis | +| Green / Positive | success | muted | fgColor-success | +| Red / Danger | danger | emphasis | fgColor-onEmphasis | +| Yellow / Warning | attention | muted | fgColor-attention | +| Orange / Critical | severe | emphasis | fgColor-onEmphasis | +| Purple / Done | done | Match intent | Match intent | +| Pink / Sponsors | sponsors | Match intent | Match intent | +| Grey / Neutral | default | bgColor-muted | fgColor-default | + +## Group Recipes + +Use \`primer tokens group list \` instead of searching property by property: + +- Forms: \`control focus outline text borderRadius stack base\` +- Modals and cards: \`overlay shadow outline borderRadius bgColor stack base\` +- Tables and lists: \`stack borderColor text bgColor control\` +- Navigation and sidebars: \`control text accent stack focus base\` +- Status and badges: \`text success danger attention severe stack\` + +## Available Groups + +${tablemark( + listTokenGroups().map(group => { + return {name: group.name, count: group.count} + }), + {columns: ['Group', 'Tokens']}, +).trimEnd()} + +--- + +${loadTokenGuide().trimEnd()}` +} + +export function getTokenUsage(): string { + const guide = loadTokenGuide() + const goldenExample = guide.match(/## Golden Example[\s\S]*?(?=\n## |$)/)?.[0].trim() + if (!goldenExample) { + throw new Error('The design token guide is missing its Golden Example section') + } + + return `# Design Token Reference Examples + +Fetch the tokens for a pattern together with \`primer tokens group list button control stack focus outline borderRadius text base\`. + +## Interaction Pattern: Button + +\`\`\`css +.button { + background-color: var(--control-bgColor-rest); + color: var(--fgColor-default); + font: var(--text-body-shorthand-medium); + padding-block: var(--control-medium-paddingBlock); + padding-inline: var(--control-medium-paddingInline-normal); + border: none; + border-radius: var(--borderRadius-medium); + cursor: pointer; + transition: background-color var(--base-duration-100) var(--base-easing-ease); +} + +.button:hover { + background-color: var(--control-bgColor-hover); +} + +.button:focus-visible { + outline: var(--focus-outline); + outline-offset: var(--outline-focus-offset); +} + +.button:active { + background-color: var(--control-bgColor-active); +} + +.button:disabled { + background-color: var(--bgColor-disabled); + color: var(--fgColor-disabled); + cursor: not-allowed; +} + +@media (prefers-reduced-motion: reduce) { + .button { + transition: none; + } +} +\`\`\` + +## Layout Pattern: Vertical Stack + +\`\`\`css +.card-stack { + display: flex; + flex-direction: column; + gap: var(--stack-gap-normal); + padding: var(--stack-padding-normal); + background-color: var(--bgColor-default); + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-large); +} + +.card-header { + padding-block-end: var(--stack-gap-condensed); + border-bottom: var(--borderWidth-thin) solid var(--borderColor-muted); +} +\`\`\` + +## Implementation Rules + +- Prefer typography shorthand tokens. +- Include all five interactive states and support reduced motion. +- Use control spacing for controls and stack spacing for layout. +- Use tokens for motion durations, easing, and border widths. + +## Upstream Reference + +The following is the reference example supplied by \`@primer/primitives\`. + +${goldenExample}` +} diff --git a/packages/cli/src/commands/tokens/index.ts b/packages/cli/src/commands/tokens/index.ts new file mode 100644 index 00000000000..c07e25b0489 --- /dev/null +++ b/packages/cli/src/commands/tokens/index.ts @@ -0,0 +1,34 @@ +import {defineCommand} from 'citty' + +export const tokens = defineCommand({ + meta: { + name: 'tokens', + description: 'Explore Primer design tokens and their usage guidelines', + }, + subCommands: { + async list() { + const mod = await import('./list') + return mod.list + }, + async get() { + const mod = await import('./get') + return mod.get + }, + async search() { + const mod = await import('./search') + return mod.search + }, + async group() { + const mod = await import('./group') + return mod.group + }, + async specs() { + const mod = await import('./specs') + return mod.specs + }, + async usage() { + const mod = await import('./usage') + return mod.usage + }, + }, +}) diff --git a/packages/cli/src/commands/tokens/list.ts b/packages/cli/src/commands/tokens/list.ts new file mode 100644 index 00000000000..eb71c7155a7 --- /dev/null +++ b/packages/cli/src/commands/tokens/list.ts @@ -0,0 +1,15 @@ +import {defineCommand} from 'citty' +import {log} from '../../console' +import {tokens} from '../../token-metadata' +import {formatTokens, listArgs} from './output' + +export const list = defineCommand({ + meta: {name: 'list', description: 'List all Primer design tokens'}, + args: listArgs, + run({args}) { + const sorted = tokens.toSorted((a, b) => { + return a.name.localeCompare(b.name) + }) + log(formatTokens(sorted, args)) + }, +}) diff --git a/packages/cli/src/commands/tokens/output.ts b/packages/cli/src/commands/tokens/output.ts new file mode 100644 index 00000000000..c8e19474898 --- /dev/null +++ b/packages/cli/src/commands/tokens/output.ts @@ -0,0 +1,75 @@ +import {tablemark} from 'tablemark' +import {paginate} from '../../pagination' +import type {Token} from '../../token-metadata' + +export const listArgs = { + json: { + type: 'boolean', + description: 'Output JSON instead of Markdown', + }, + paginate: { + type: 'boolean', + description: 'Paginate results (default: 10 tokens per page, starting at page 1)', + }, + limit: { + type: 'string', + description: 'Tokens per page (enables pagination)', + valueHint: 'number', + }, + page: { + type: 'string', + description: 'Page number, starting at 1 (enables pagination)', + valueHint: 'number', + }, +} as const + +interface OutputArgs { + json?: boolean + paginate?: boolean + limit?: string + page?: string +} + +export function formatToken(token: Token): string { + return `### ${token.name} + +- **Value:** \`${token.value}\` +- **Type:** ${token.type} +- **Group:** ${token.group} +- **Usage:** ${token.useCase || '(none)'} +- **Rules:** ${token.rules || '(none)'}` +} + +export function formatTokenBundle(tokens: ReadonlyArray): string { + const groups = new Map>() + for (const token of tokens) { + const group = groups.get(token.group) ?? [] + group.push(token) + groups.set(token.group, group) + } + return Array.from(groups, ([name, entries]) => { + return `## ${name}\n\n${entries.map(formatToken).join('\n\n')}` + }).join('\n\n') +} + +export function formatTokens(tokens: ReadonlyArray, args: OutputArgs, detailed = false): string { + const {results, pagination} = paginate(tokens, args) + if (args.json) { + return JSON.stringify(pagination ? {tokens: results, pagination} : results, null, 2) + } + const output = detailed + ? formatTokenBundle(results) || 'No tokens found.' + : results.length > 0 + ? tablemark( + results.map(token => { + return {name: token.name, value: token.value, group: token.group} + }), + {columns: ['Name', 'Value', 'Group']}, + ).trimEnd() + : '| Name | Value | Group |\n| --- | --- | --- |' + + return [ + output, + ...(pagination ? ['', `Page ${pagination.page} of ${pagination.totalPages} (${pagination.total} tokens)`] : []), + ].join('\n') +} diff --git a/packages/cli/src/commands/tokens/search.ts b/packages/cli/src/commands/tokens/search.ts new file mode 100644 index 00000000000..a3e9421d654 --- /dev/null +++ b/packages/cli/src/commands/tokens/search.ts @@ -0,0 +1,25 @@ +import {defineCommand} from 'citty' +import {log} from '../../console' +import {paginate} from '../../pagination' +import {searchTokens} from '../../token-metadata' +import {formatTokens, listArgs} from './output' + +export const search = defineCommand({ + meta: {name: 'search', description: 'Find design tokens by keywords, group, or both'}, + args: { + ...listArgs, + query: {type: 'positional', description: 'Search keywords (all must match)', required: false, default: ''}, + group: {type: 'string', description: 'Token group or alias (for example bgColor, background, or spacing)'}, + limit: {type: 'string', description: 'Results per page (default: 15, maximum: 100)', valueHint: 'number'}, + paginate: {type: 'boolean', description: 'Search results are always paginated (default: 15 per page)'}, + }, + run({args}) { + const outputArgs = {...args, limit: args.limit ?? '15'} + const {pagination} = paginate([], outputArgs) + if (pagination && pagination.limit > 100) { + throw new Error('--limit must be between 1 and 100 for token searches') + } + const matches = searchTokens(args._.join(' '), args.group) + log(formatTokens(matches, outputArgs, true)) + }, +}) diff --git a/packages/cli/src/commands/tokens/specs.ts b/packages/cli/src/commands/tokens/specs.ts new file mode 100644 index 00000000000..800190fc483 --- /dev/null +++ b/packages/cli/src/commands/tokens/specs.ts @@ -0,0 +1,10 @@ +import {defineCommand} from 'citty' +import {log} from '../../console' +import {getTokenSpecs} from './guidelines' + +export const specs = defineCommand({ + meta: {name: 'specs', description: 'Get the token logic matrix, available groups, and design token specifications'}, + run() { + log(getTokenSpecs()) + }, +}) diff --git a/packages/cli/src/commands/tokens/tokens.test.ts b/packages/cli/src/commands/tokens/tokens.test.ts new file mode 100644 index 00000000000..117f7c75e5d --- /dev/null +++ b/packages/cli/src/commands/tokens/tokens.test.ts @@ -0,0 +1,324 @@ +import {runCommand} from 'citty' +import {afterEach, describe, expect, it, vi} from 'vitest' +import sizeCoarse from '@primer/primitives/dist/docs/functional/size/size-coarse.json' with {type: 'json'} +import light from '@primer/primitives/dist/docs/functional/themes/light.json' with {type: 'json'} +import {tokens as command} from './index' +import {log} from '../../console' +import {getGroupTokens, getToken, loadTokenGuide, tokens} from '../../token-metadata' + +vi.mock('../../console') + +const emit = vi.mocked(log) +const sorted = tokens.toSorted((a, b) => { + return a.name.localeCompare(b.name) +}) + +afterEach(() => { + emit.mockClear() +}) + +describe('tokens list', () => { + it('lists the complete deduplicated catalog in a Markdown table', async () => { + await runCommand(command, {rawArgs: ['list']}) + + const rows = emit.mock.calls[0][0].split('\n').map(line => { + return line + .split('|') + .slice(1, -1) + .map(cell => { + return cell.trim() + }) + }) + expect(rows[0]).toEqual(['Name', 'Value', 'Group']) + expect(rows.slice(2)).toEqual( + sorted.map(token => { + return [token.name, token.value, token.group] + }), + ) + expect( + new Set( + rows.slice(2).map(row => { + return row[0] + }), + ).size, + ).toBe(tokens.length) + }) + + it('returns token values, types, and guidance as JSON', async () => { + await runCommand(command, {rawArgs: ['list', '--json']}) + + expect(JSON.parse(emit.mock.calls[0][0])).toEqual(sorted) + expect(JSON.parse(emit.mock.calls[0][0])).toContainEqual( + expect.objectContaining({ + name: 'bgColor-default', + value: light['bgColor-default'].value, + type: 'color', + useCase: expect.stringContaining('card-background'), + rules: expect.stringContaining('primary background'), + }), + ) + }) + + it.each([ + {flags: ['--paginate'], page: 1, limit: 10}, + {flags: ['--page', '2'], page: 2, limit: 10}, + {flags: ['--limit', '3', '--page', '2'], page: 2, limit: 3}, + ])('includes page information with $flags', async ({flags, page, limit}) => { + await runCommand(command, {rawArgs: ['list', '--json', ...flags]}) + + expect(JSON.parse(emit.mock.calls[0][0])).toEqual({ + tokens: sorted.slice((page - 1) * limit, page * limit), + pagination: {page, limit, total: tokens.length, totalPages: Math.ceil(tokens.length / limit)}, + }) + }) + + it('retains table headers and page information beyond the last page', async () => { + const page = tokens.length + 1 + await runCommand(command, {rawArgs: ['list', '--page', String(page)]}) + + expect(emit).toHaveBeenCalledExactlyOnceWith( + `| Name | Value | Group |\n| --- | --- | --- |\n\nPage ${page} of ${Math.ceil(tokens.length / 10)} (${tokens.length} tokens)`, + ) + }) +}) + +describe('tokens get', () => { + it.each(['bgColor-default', 'BGCOLOR-DEFAULT', 'var( --bgColor-default )'])( + 'resolves %s to an exact token and includes upstream guidance', + async name => { + await runCommand(command, {rawArgs: ['get', name, '--json']}) + + expect(JSON.parse(emit.mock.calls[0][0])).toEqual(getToken('bgColor-default')) + expect(getToken('bgColor-default').useCase).toContain('card-background') + expect(getToken('bgColor-default').rules).toContain('primary background') + }, + ) + + it('accepts a CSS custom property following the end-of-options marker', async () => { + await runCommand(command, {rawArgs: ['get', '--json', '--', '--bgColor-default']}) + + expect(JSON.parse(emit.mock.calls[0][0]).name).toBe('bgColor-default') + }) + + it('formats Markdown with value, type, group, usage, and rules', async () => { + await runCommand(command, {rawArgs: ['get', 'bgColor-default']}) + + const output = emit.mock.calls[0][0] + expect(output).toContain('### bgColor-default') + expect(output).toContain(`**Value:** \`${light['bgColor-default'].value}\``) + expect(output).toContain('**Type:** color') + expect(output).toContain('**Group:** Background Colors') + expect(output).toContain('**Usage:** card-background') + expect(output).toContain('**Rules:** Use as the primary background') + }) + + it.each([ + {name: 'base-duration-100', value: '100ms'}, + {name: 'base-easing-ease', value: 'cubic-bezier(0.25, 0.1, 0.25, 1)'}, + {name: 'base-text-weight-light', value: '300'}, + {name: 'base-text-lineHeight-loose', value: '1.75'}, + ])('serializes structured and numeric values for $name', async ({name, value}) => { + await runCommand(command, {rawArgs: ['get', name, '--json']}) + + expect(JSON.parse(emit.mock.calls[0][0]).value).toBe(value) + }) + + it('uses JSON extension guidance where the Markdown spec has no entry', async () => { + await runCommand(command, {rawArgs: ['get', 'base-easing-ease', '--json']}) + + expect(JSON.parse(emit.mock.calls[0][0])).toMatchObject({ + useCase: expect.stringContaining('hover-state'), + rules: 'Use for hover state changes.', + }) + }) + + it('preserves the MCP source precedence for duplicate coarse/fine tokens', async () => { + await runCommand(command, {rawArgs: ['get', 'control-minTarget-auto', '--json']}) + + expect(JSON.parse(emit.mock.calls[0][0]).value).toBe(sizeCoarse['control-minTarget-auto'].value) + }) + + it.each(['unknown-token', 'default'])('rejects unknown or partial names (%s)', async name => { + await expect(runCommand(command, {rawArgs: ['get', name]})).rejects.toThrow(`No token found for "${name}"`) + expect(emit).not.toHaveBeenCalled() + }) + + it('requires a token name', async () => { + await expect(runCommand(command, {rawArgs: ['get']})).rejects.toThrow('Missing required positional argument') + expect(emit).not.toHaveBeenCalled() + }) +}) + +describe('tokens search', () => { + it('defaults to 15 results and includes page information', async () => { + await runCommand(command, {rawArgs: ['search', '--group', 'bgColor', '--json']}) + + const matches = getGroupTokens(['bgColor']) + expect(matches.length).toBeGreaterThan(15) + expect(JSON.parse(emit.mock.calls[0][0])).toEqual({ + tokens: matches.slice(0, 15), + pagination: {page: 1, limit: 15, total: matches.length, totalPages: Math.ceil(matches.length / 15)}, + }) + }) + + it('supports quoted keyword AND searches and aliases in an explicit group', async () => { + await runCommand(command, {rawArgs: ['search', 'body medium', '--group', 'typography', '--json']}) + + const result = JSON.parse(emit.mock.calls[0][0]) + expect(result.tokens.length).toBeGreaterThan(0) + for (const token of result.tokens) { + expect(token.name).toMatch(/^text-/) + const searchable = `${token.name} ${token.useCase} ${token.rules} ${token.group}`.toLowerCase() + expect(searchable).toContain('body') + expect(searchable).toContain('medium') + } + }) + + it('extracts group aliases from unquoted words and resolves semantic color intent', async () => { + await runCommand(command, {rawArgs: ['search', 'red', 'bordercolor', '--json']}) + + const result = JSON.parse(emit.mock.calls[0][0]) + expect(result.tokens.length).toBeGreaterThan(0) + for (const token of result.tokens) { + expect(token.name).toMatch(/^borderColor-/) + expect(`${token.name} ${token.useCase} ${token.rules}`).toMatch(/danger/i) + } + }) + + it('accepts a group alias as the whole query', async () => { + await runCommand(command, {rawArgs: ['search', 'spacing', '--json']}) + + expect(JSON.parse(emit.mock.calls[0][0]).tokens).toEqual(getGroupTokens(['stack']).slice(0, 15)) + }) + + it('searches guidance as well as names', async () => { + await runCommand(command, {rawArgs: ['search', 'card-background', '--json']}) + + expect(JSON.parse(emit.mock.calls[0][0]).tokens).toContainEqual(getToken('bgColor-default')) + }) + + it('allows another page and an explicit result limit', async () => { + await runCommand(command, { + rawArgs: ['search', '--group', 'background color', '--limit', '2', '--page', '2', '--json'], + }) + + const matches = getGroupTokens(['bgColor']) + expect(JSON.parse(emit.mock.calls[0][0])).toEqual({ + tokens: matches.slice(2, 4), + pagination: {page: 2, limit: 2, total: matches.length, totalPages: Math.ceil(matches.length / 2)}, + }) + }) + + it('returns an empty paginated JSON result for no matches', async () => { + await runCommand(command, {rawArgs: ['search', 'danger nonexistent-keyword', '--json']}) + + expect(JSON.parse(emit.mock.calls[0][0])).toEqual({ + tokens: [], + pagination: {page: 1, limit: 15, total: 0, totalPages: 0}, + }) + }) + + it('includes usage and rules in Markdown search results', async () => { + await runCommand(command, {rawArgs: ['search', 'card-background']}) + + const output = emit.mock.calls[0][0] + expect(output).toContain('### bgColor-default') + expect(output).toContain('**Usage:**') + expect(output).toContain('**Rules:**') + expect(output).toContain('Page 1 of') + }) + + it('reports no matches explicitly in Markdown', async () => { + await runCommand(command, {rawArgs: ['search', 'nonexistent-keyword']}) + + expect(emit.mock.calls[0][0]).toContain('No tokens found.') + }) + + it.each([{flags: []}, {flags: ['--group', 'nonexistent-group']}])( + 'rejects missing input or unknown groups ($flags)', + async ({flags}) => { + await expect(runCommand(command, {rawArgs: ['search', ...flags]})).rejects.toThrow() + expect(emit).not.toHaveBeenCalled() + }, + ) + + it.each(['0', '-1', '1.5', '101', 'invalid', '9007199254740992'])( + 'rejects invalid search limits (%s)', + async limit => { + await expect(runCommand(command, {rawArgs: ['search', 'spacing', '--limit', limit]})).rejects.toThrow('--limit') + expect(emit).not.toHaveBeenCalled() + }, + ) +}) + +describe('tokens group list', () => { + it('bundles multiple groups without duplicates and includes group usage hints', async () => { + await runCommand(command, {rawArgs: ['group', 'list', 'typography', 'text', 'background', '--json']}) + + expect(JSON.parse(emit.mock.calls[0][0])).toEqual({ + tokens: getGroupTokens(['text', 'bgColor']), + groups: ['text', 'bgColor'], + hints: [expect.stringContaining('line-height')], + }) + }) + + it('paginates JSON bundles with 10 items by default', async () => { + await runCommand(command, {rawArgs: ['group', 'list', 'control', 'button', '--paginate', '--json']}) + + const matches = getGroupTokens(['control', 'button']) + expect(JSON.parse(emit.mock.calls[0][0])).toMatchObject({ + tokens: matches.slice(0, 10), + pagination: {page: 1, limit: 10, total: matches.length, totalPages: Math.ceil(matches.length / 10)}, + }) + }) + + it('emits Markdown bundles with value, rules, and usage hints', async () => { + await runCommand(command, {rawArgs: ['group', 'list', 'control', 'focus', '--limit', '2']}) + + const output = emit.mock.calls[0][0] + expect(output).toContain('**Value:**') + expect(output).toContain('**Rules:**') + expect(output).toContain('## Usage Guidance') + expect(output).toContain('For buttons, use the `button` group.') + expect(output).toContain('Page 1 of') + }) + + it.each([{groups: []}, {groups: ['control', 'unknown-group']}])( + 'rejects missing or unknown groups ($groups)', + async ({groups}) => { + await expect(runCommand(command, {rawArgs: ['group', 'list', ...groups]})).rejects.toThrow() + expect(emit).not.toHaveBeenCalled() + }, + ) +}) + +describe('token specifications and usage', () => { + it('includes current groups, semantic mapping, recipes, and the full upstream guide', async () => { + await runCommand(command, {rawArgs: ['specs']}) + + const output = emit.mock.calls[0][0] + expect(output).toContain('# Design Token Specifications') + expect(output).toContain('## Available Groups') + expect(output).toContain('## Group Recipes') + expect(output).toContain('primer tokens group list') + expect(output).toContain('bgColor') + expect(output).toContain('borderRadius') + expect(output).toContain(loadTokenGuide().trimEnd()) + }) + + it('includes Button/Stack examples and the upstream Golden Example', async () => { + await runCommand(command, {rawArgs: ['usage', 'get']}) + + const output = emit.mock.calls[0][0] + expect(output).toContain('## Interaction Pattern: Button') + expect(output).toContain('## Layout Pattern: Vertical Stack') + expect(output).toContain('## Golden Example: Reference Component') + expect(output).toContain('prefers-reduced-motion') + expect(output).not.toContain('## Hallucination Guard') + for (const match of output.matchAll(/var\((--[^),\s]+)/g)) { + expect(() => { + getToken(match[1]) + }).not.toThrow() + } + }) +}) diff --git a/packages/cli/src/commands/tokens/usage/get.ts b/packages/cli/src/commands/tokens/usage/get.ts new file mode 100644 index 00000000000..d33089edecf --- /dev/null +++ b/packages/cli/src/commands/tokens/usage/get.ts @@ -0,0 +1,10 @@ +import {defineCommand} from 'citty' +import {log} from '../../../console' +import {getTokenUsage} from '../guidelines' + +export const get = defineCommand({ + meta: {name: 'get', description: 'Get reference Button and Stack CSS patterns and implementation rules'}, + run() { + log(getTokenUsage()) + }, +}) diff --git a/packages/cli/src/commands/tokens/usage/index.ts b/packages/cli/src/commands/tokens/usage/index.ts new file mode 100644 index 00000000000..b1f883f0812 --- /dev/null +++ b/packages/cli/src/commands/tokens/usage/index.ts @@ -0,0 +1,11 @@ +import {defineCommand} from 'citty' + +export const usage = defineCommand({ + meta: {name: 'usage', description: 'Explore reference patterns for applying design tokens'}, + subCommands: { + async get() { + const mod = await import('./get') + return mod.get + }, + }, +}) diff --git a/packages/cli/src/documentation.ts b/packages/cli/src/documentation.ts index f42e942fa7a..de7968ca80d 100644 --- a/packages/cli/src/documentation.ts +++ b/packages/cli/src/documentation.ts @@ -12,6 +12,25 @@ export class DocumentationFetchError extends Error { } } +export async function fetchPreferredMarkdown(url: URL, name: string): Promise { + const llmsUrl = new URL(url) + llmsUrl.pathname = `${url.pathname.replace(/\/$/, '')}/llms.txt` + const response = await fetch(llmsUrl, {signal: AbortSignal.timeout(10_000)}) + if (response.status === 404) { + return fetchMarkdown(url, name) + } + if (!response.ok) { + throw new DocumentationFetchError(response.status, name, response.statusText) + } + + const documentation = (await response.text()).trimEnd() + if (!documentation) { + throw new Error(`Documentation for ${name} is empty`) + } + + return documentation +} + export async function fetchMarkdown(url: URL, name: string): Promise { const response = await fetch(url, {signal: AbortSignal.timeout(10_000)}) if (!response.ok) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2ef2bcacbc7..ceb99834b1c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -28,6 +28,10 @@ const main = defineCommand({ const mod = await import('./commands/scenarios') return mod.scenarios }, + async tokens() { + const mod = await import('./commands/tokens') + return mod.tokens + }, }, }) diff --git a/packages/cli/src/token-metadata.ts b/packages/cli/src/token-metadata.ts new file mode 100644 index 00000000000..294b26e2a4f --- /dev/null +++ b/packages/cli/src/token-metadata.ts @@ -0,0 +1,349 @@ +import {readFileSync} from 'node:fs' +import {createRequire} from 'node:module' +import baseMotion from '@primer/primitives/dist/docs/base/motion/motion.json' with {type: 'json'} +import baseSize from '@primer/primitives/dist/docs/base/size/size.json' with {type: 'json'} +import baseTypography from '@primer/primitives/dist/docs/base/typography/typography.json' with {type: 'json'} +import border from '@primer/primitives/dist/docs/functional/size/border.json' with {type: 'json'} +import radius from '@primer/primitives/dist/docs/functional/size/radius.json' with {type: 'json'} +import sizeCoarse from '@primer/primitives/dist/docs/functional/size/size-coarse.json' with {type: 'json'} +import sizeFine from '@primer/primitives/dist/docs/functional/size/size-fine.json' with {type: 'json'} +import size from '@primer/primitives/dist/docs/functional/size/size.json' with {type: 'json'} +import light from '@primer/primitives/dist/docs/functional/themes/light.json' with {type: 'json'} +import typography from '@primer/primitives/dist/docs/functional/typography/typography.json' with {type: 'json'} + +export interface Token { + name: string + value: string + type: string + group: string + useCase: string + rules: string +} + +interface SourceToken { + name: string + type: string + value: unknown + $extensions?: Record +} + +interface Guidance { + group?: string + useCase: string + rules: string +} + +const require = createRequire(import.meta.url) + +export function loadTokenGuide(): string { + return readFileSync(require.resolve('@primer/primitives/DESIGN_TOKENS_GUIDE.md'), 'utf8') +} + +function parseGuidance(): Map { + const spec = readFileSync(require.resolve('@primer/primitives/DESIGN_TOKENS_SPEC.md'), 'utf8') + const result = new Map() + let name: string | undefined + let group = '' + let useCase = '' + let rules = '' + let description: Array = [] + + function save() { + if (name) { + result.set(name, {group, useCase: useCase || description.join(' '), rules}) + } + } + + for (const line of spec.split('\n')) { + const heading = line.match(/^(##|###) (.+)$/) + if (heading) { + save() + if (heading[1] === '##') { + group = heading[2].trim() + } + name = heading[1] === '###' ? heading[2].trim() : undefined + useCase = '' + rules = '' + description = [] + continue + } + if (!name) { + continue + } + const usage = line.match(/^\*\*U:\*\*\s*(.+)$/) + const rule = line.match(/^\*\*R:\*\*\s*(.+)$/) + if (usage) { + useCase = usage[1].trim() + } else if (rule) { + rules = rule[1].trim() + } else if (!useCase && line.trim() && !line.startsWith('**') && !line.startsWith('#')) { + description.push(line.trim()) + } + } + save() + return result +} + +function formatValue(token: SourceToken): string { + const value = token.value + if (typeof value === 'string' || typeof value === 'number') { + return String(value) + } + if ( + token.type === 'duration' && + value !== null && + typeof value === 'object' && + 'value' in value && + typeof value.value === 'number' && + 'unit' in value && + typeof value.unit === 'string' + ) { + return `${value.value}${value.unit}` + } + if (token.type === 'cubicBezier' && Array.isArray(value)) { + return `cubic-bezier(${value.join(', ')})` + } + + throw new Error(`Unsupported value for token "${token.name}" (${token.type})`) +} + +function extensionGuidance(token: SourceToken): Guidance { + const metadata = token.$extensions?.['org.primer.llm'] + if (metadata === null || typeof metadata !== 'object') { + return {useCase: '', rules: ''} + } + const usage = 'usage' in metadata && Array.isArray(metadata.usage) ? metadata.usage : [] + const rules = 'rules' in metadata && typeof metadata.rules === 'string' ? metadata.rules : '' + return { + useCase: usage + .filter((value: unknown): value is string => { + return typeof value === 'string' + }) + .join(', '), + rules: 'doNotUse' in metadata && metadata.doNotUse === true ? `${rules} MUST NOT be used.`.trim() : rules, + } +} + +const sources: ReadonlyArray> = [ + Object.values(baseMotion), + Object.values(baseSize), + Object.values(baseTypography), + Object.values(light), + Object.values(size), + Object.values(sizeCoarse), + Object.values(sizeFine), + Object.values(border), + Object.values(radius), + Object.values(typography), +] +const guidance = parseGuidance() +const unique = new Map() + +for (const source of sources.flat()) { + if (unique.has(source.name)) { + continue + } + const fromSpec = guidance.get(source.name) + const fromExtension = extensionGuidance(source) + unique.set(source.name, { + name: source.name, + value: formatValue(source), + type: source.type, + group: fromSpec?.group || source.name.split('-')[0], + useCase: fromSpec?.useCase || fromExtension.useCase, + rules: fromSpec?.rules || fromExtension.rules, + }) +} + +export const tokens = Array.from(unique.values()) + +const groupAliases = new Map( + Object.entries({ + bgcolor: 'bgColor', + fgcolor: 'fgColor', + bordercolor: 'borderColor', + border: 'border', + shadow: 'shadow', + focus: 'focus', + color: 'color', + button: 'button', + control: 'control', + overlay: 'overlay', + borderradius: 'borderRadius', + boxshadow: 'boxShadow', + fontstack: 'fontStack', + spinner: 'spinner', + background: 'bgColor', + backgroundcolor: 'bgColor', + bg: 'bgColor', + foreground: 'fgColor', + foregroundcolor: 'fgColor', + textcolor: 'fgColor', + fg: 'fgColor', + radius: 'borderRadius', + rounded: 'borderRadius', + elevation: 'overlay', + depth: 'overlay', + btn: 'button', + typography: 'text', + font: 'text', + text: 'text', + 'line-height': 'text', + lineheight: 'text', + leading: 'text', + stack: 'stack', + controlstack: 'controlStack', + padding: 'stack', + margin: 'stack', + gap: 'stack', + spacing: 'stack', + layout: 'stack', + offset: 'focus', + outline: 'outline', + ring: 'focus', + borderwidth: 'borderWidth', + line: 'borderColor', + stroke: 'borderColor', + separator: 'borderColor', + red: 'danger', + green: 'success', + yellow: 'attention', + orange: 'severe', + blue: 'accent', + purple: 'done', + pink: 'sponsors', + grey: 'neutral', + gray: 'neutral', + light: 'muted', + subtle: 'muted', + dark: 'emphasis', + strong: 'emphasis', + intense: 'emphasis', + bold: 'emphasis', + vivid: 'emphasis', + highlight: 'emphasis', + }), +) + +export const groupHints = new Map( + Object.entries({ + control: '`control` tokens are for form inputs and checkboxes. For buttons, use the `button` group.', + button: '`button` tokens are for standard triggers. For form fields, use the `control` group.', + text: 'Use shorthand tokens where possible. Caption, display, codeBlock, and codeInline do not support size suffixes. If splitting typography, fetch line-height tokens instead of using raw numbers.', + fgColor: 'Use `fgColor` for text. For borders, use `borderColor`.', + borderWidth: '`borderWidth` only has sizing values. For border colors, use `borderColor` or `border`.', + animation: 'Apply duration and easing to the base class, not the hover state. Use duration and easing tokens.', + }), +) + +export function listTokenGroups() { + const groups = new Map() + for (const token of tokens) { + const group = token.name.split('-')[0] + groups.set(group, (groups.get(group) ?? 0) + 1) + } + return Array.from(groups, ([name, count]) => { + return {name, count} + }).toSorted((a, b) => { + return a.name.localeCompare(b.name) + }) +} + +export function resolveGroup(group: string): string { + const normalized = group.toLowerCase().replace(/\s+/g, '') + return ( + groupAliases.get(normalized) ?? + listTokenGroups().find(entry => { + return entry.name.toLowerCase() === normalized + })?.name ?? + tokens.find(token => { + return token.group.toLowerCase().replace(/\s+/g, '') === normalized + })?.group ?? + group + ) +} + +export function matchesGroup(token: Token, group: string): boolean { + const normalized = group.toLowerCase() + const parts = token.name.toLowerCase().split('-') + const intents = [ + 'danger', + 'success', + 'attention', + 'severe', + 'accent', + 'done', + 'sponsors', + 'neutral', + 'muted', + 'emphasis', + ] + return ( + parts[0] === normalized || + token.group.toLowerCase() === normalized || + (intents.includes(normalized) && parts.includes(normalized)) + ) +} + +export function getGroupTokens(groups: ReadonlyArray): Array { + const resolved = groups.map(resolveGroup) + for (const group of resolved) { + if ( + !tokens.some(token => { + return matchesGroup(token, group) + }) + ) { + throw new Error(`Unknown token group "${group}". Use "primer tokens specs" to see available groups.`) + } + } + return tokens.filter(token => { + return resolved.some(group => { + return matchesGroup(token, group) + }) + }) +} + +export function searchTokens(query: string, group?: string): Array { + let effectiveGroup = group ? resolveGroup(group) : undefined + const keywords: Array = [] + const canonicalGroups = new Set( + listTokenGroups().map(entry => { + return entry.name + }), + ) + + for (const word of query.toLowerCase().split(/\s+/).filter(Boolean)) { + const alias = groupAliases.get(word) + if (alias && canonicalGroups.has(alias) && !effectiveGroup) { + effectiveGroup = alias + } else { + keywords.push(alias && !canonicalGroups.has(alias) ? alias.toLowerCase() : word) + } + } + if (keywords.length === 0 && !effectiveGroup) { + throw new Error('Provide a query, --group, or both. Use "primer tokens specs" to see available groups.') + } + + const candidates = effectiveGroup ? getGroupTokens([effectiveGroup]) : tokens + return candidates.filter(token => { + const text = `${token.name} ${token.useCase} ${token.rules} ${token.group}`.toLowerCase() + return keywords.every(keyword => { + return text.includes(keyword) + }) + }) +} + +export function getToken(name: string): Token { + const normalized = name + .trim() + .replace(/^var\(\s*(--[^\s)]+)\s*\)$/, '$1') + .replace(/^--/, '') + .toLowerCase() + const match = tokens.find(token => { + return token.name.toLowerCase() === normalized + }) + if (!match) { + throw new Error(`No token found for "${name}". Use "primer tokens list" or "primer tokens search" to find a token.`) + } + return match +} From 75e8bdf5a5ab2b0554fa93f57d0886fca8655143 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Tue, 6 Oct 2026 19:14:14 -0500 Subject: [PATCH 09/13] docs(skills): add skill for primer-cli --- skills/cli/SKILL.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 skills/cli/SKILL.md diff --git a/skills/cli/SKILL.md b/skills/cli/SKILL.md new file mode 100644 index 00000000000..0e459c6e9eb --- /dev/null +++ b/skills/cli/SKILL.md @@ -0,0 +1,14 @@ +--- +name: primer-cli +description: | + Use when answering questions about Primer. Use when working on UI in order to find reusable components, design tokens, icons, patterns, scenarios, and other resources. +--- + +The Primer CLI is available through the `@primer/cli` package. Check to see if +the package is installed by running `npm list -g @primer/cli`. If it is not +installed, you can install it globally with `npm install -g @primer/cli`. If the +locally installed version is not the latest version, suggest updating it by running `npm update -g @primer/cli`. + +Use the CLI to discover and explore Primer resources in order to build UI. The +`--help` flag is available across all commands in order to find additional +information. From 56551775130c1589990f8f0df9a32c528ed1ee98 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Tue, 6 Oct 2026 19:17:53 -0500 Subject: [PATCH 10/13] Update Primer CLI installation instructions --- skills/cli/SKILL.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/skills/cli/SKILL.md b/skills/cli/SKILL.md index 0e459c6e9eb..9a0405d7fc3 100644 --- a/skills/cli/SKILL.md +++ b/skills/cli/SKILL.md @@ -4,11 +4,6 @@ description: | Use when answering questions about Primer. Use when working on UI in order to find reusable components, design tokens, icons, patterns, scenarios, and other resources. --- -The Primer CLI is available through the `@primer/cli` package. Check to see if -the package is installed by running `npm list -g @primer/cli`. If it is not -installed, you can install it globally with `npm install -g @primer/cli`. If the -locally installed version is not the latest version, suggest updating it by running `npm update -g @primer/cli`. +The Primer CLI is available through the `@primer/cli` package. Check to see if the package is installed in the project or globally. If it is not installed, you can install it with `npm install @primer/cli` within a project or with the `-g` flag to install it globally. If the locally installed version is not the latest version, suggest updating it by running `npm update @primer/cli` (or with the `-g` flag if global). -Use the CLI to discover and explore Primer resources in order to build UI. The -`--help` flag is available across all commands in order to find additional -information. +Use the CLI to discover and explore Primer resources in order to build UI. The `--help` flag is available across all commands in order to find additional information. From 710ca786fb8bd922b14591ea8334242a5790c65c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:35:57 +0000 Subject: [PATCH 11/13] fix(cli): remove redundant prepack build hook Co-authored-by: joshblack <3901764+joshblack@users.noreply.github.com> --- packages/cli/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 176aeb2a3b8..5c5e50485bc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,7 +19,6 @@ "scripts": { "build": "rolldown -c", "clean": "rimraf dist", - "prepack": "npm run build", "type-check": "tsc --noEmit", "watch": "rolldown -c -w" }, From 4bd0972f53b0c7278b725814045c8cfc779fba82 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 7 Oct 2026 11:09:08 -0500 Subject: [PATCH 12/13] test(cli): separate command behavior from routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>\nCopilot-Session: 9560da90-256a-48e9-84bb-a76138fd4be7 --- contributor-docs/testing.md | 2 + .../commands/components/components.test.ts | 50 +++--- .../commands/components/guidelines.test.ts | 25 ++- .../commands/guidelines/guidelines.test.ts | 22 +-- .../src/commands/patterns/patterns.test.ts | 56 +++---- .../cli/src/commands/tokens/tokens.test.ts | 67 ++++---- packages/cli/src/routing.test.ts | 147 ++++++++++++++++++ 7 files changed, 261 insertions(+), 108 deletions(-) create mode 100644 packages/cli/src/routing.test.ts diff --git a/contributor-docs/testing.md b/contributor-docs/testing.md index a31c734a871..ed41a02ccd2 100644 --- a/contributor-docs/testing.md +++ b/contributor-docs/testing.md @@ -88,6 +88,8 @@ We are slowly moving away from using snapshots as a way to test visual changes o By default, Vitest fails when tests emit unexpected console output in CI. For local debugging, it is disabled unless you opt in with `VITEST_FAIL_ON_CONSOLE=true`. +For `@primer/cli`, statically import leaf commands in behavior tests and call `runCommand` with only that command's arguments. Keep network requests and output mocked. This preserves argument parsing and validation coverage without counting cold lazy imports against the test timeout. Keep command-tree routing coverage separate, with leaf handlers stubbed so those tests only exercise dispatch. Run the CLI suite with `npm test -- --run --project=@primer/cli`. + ## Interaction Tests ### As A Part Of Unit Tests diff --git a/packages/cli/src/commands/components/components.test.ts b/packages/cli/src/commands/components/components.test.ts index 3c343a9e76d..28309968a50 100644 --- a/packages/cli/src/commands/components/components.test.ts +++ b/packages/cli/src/commands/components/components.test.ts @@ -1,7 +1,9 @@ import {runCommand} from 'citty' import componentsMetadata from '@primer/react/generated/components.json' with {type: 'json'} import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {components as component} from './index' +import {list} from './list' +import {get} from './get' +import {get as getExamples} from './examples/get' import {log} from '../../console' vi.mock('../../console') @@ -22,7 +24,7 @@ describe('components list', () => { }) it('lists all components in a Markdown table by default', async () => { - await runCommand(component, {rawArgs: ['list']}) + await runCommand(list, {rawArgs: []}) expect(emit).toHaveBeenCalledTimes(1) const output = emit.mock.calls[0][0] @@ -53,13 +55,13 @@ describe('components list', () => { }) it('outputs component IDs and names as a JSON array', async () => { - await runCommand(component, {rawArgs: ['list', '--json']}) + await runCommand(list, {rawArgs: ['--json']}) expect(emit).toHaveBeenCalledExactlyOnceWith(JSON.stringify(components, null, 2)) }) it('uses the first page of 10 components and includes page info when pagination is enabled', async () => { - await runCommand(component, {rawArgs: ['list', '--json', '--paginate']}) + await runCommand(list, {rawArgs: ['--json', '--paginate']}) expect(emit).toHaveBeenCalledExactlyOnceWith( JSON.stringify( @@ -100,7 +102,7 @@ describe('components list', () => { end: components.length, }, ])('paginates JSON with $flags', async ({flags, page, limit, start, end}) => { - await runCommand(component, {rawArgs: ['list', '--json', ...flags]}) + await runCommand(list, {rawArgs: ['--json', ...flags]}) expect(emit).toHaveBeenCalledExactlyOnceWith( JSON.stringify( @@ -115,7 +117,7 @@ describe('components list', () => { }) it('paginates Markdown output', async () => { - await runCommand(component, {rawArgs: ['list', '--limit', '1', '--page', '2']}) + await runCommand(list, {rawArgs: ['--limit', '1', '--page', '2']}) expect(emit).toHaveBeenCalledExactlyOnceWith( `| ID | Name |\n| :---------- | :--------- |\n| action_list | ActionList |\n\nPage 2 of ${components.length} (${components.length} components)`, @@ -124,7 +126,7 @@ describe('components list', () => { it('includes page info for an empty Markdown page', async () => { const page = components.length + 1 - await runCommand(component, {rawArgs: ['list', '--page', String(page)]}) + await runCommand(list, {rawArgs: ['--page', String(page)]}) expect(emit).toHaveBeenCalledExactlyOnceWith( `| ID | Name |\n| --- | --- |\n\nPage ${page} of ${Math.ceil(components.length / 10)} (${components.length} components)`, @@ -133,12 +135,12 @@ describe('components list', () => { it.each(['limit', 'page'])('rejects invalid --%s values without writing output', async flag => { for (const value of ['0', '-1', '1.5', 'abc', '1e2', 'Infinity', '9007199254740992', '']) { - await expect(runCommand(component, {rawArgs: ['list', `--${flag}=${value}`]})).rejects.toThrow( + await expect(runCommand(list, {rawArgs: [`--${flag}=${value}`]})).rejects.toThrow( `--${flag} must be a positive safe integer`, ) } - await expect(runCommand(component, {rawArgs: ['list', `--${flag}`]})).rejects.toThrow( + await expect(runCommand(list, {rawArgs: [`--${flag}`]})).rejects.toThrow( `--${flag} must be a positive safe integer`, ) expect(emit).not.toHaveBeenCalled() @@ -173,7 +175,7 @@ describe('components get', () => { ])('fetches documentation for $identifier from $slug', async ({identifier, slug}) => { fetchMock.mockResolvedValue(new Response('# Component\n\nOfficial documentation.\n')) - await runCommand(component, {rawArgs: ['get', identifier]}) + await runCommand(get, {rawArgs: [identifier]}) expect(fetchMock).toHaveBeenCalledExactlyOnceWith( new URL(`/product/components/${slug}/llms.txt`, 'https://primer.style'), @@ -183,7 +185,7 @@ describe('components get', () => { }) it('rejects unknown components without fetching or emitting output', async () => { - await expect(runCommand(component, {rawArgs: ['get', 'UnknownComponent']})).rejects.toThrow( + await expect(runCommand(get, {rawArgs: ['UnknownComponent']})).rejects.toThrow( 'No component found for "UnknownComponent". Use "primer components list" to see available components.', ) @@ -192,7 +194,7 @@ describe('components get', () => { }) it('requires a component ID or name', async () => { - await expect(runCommand(component, {rawArgs: ['get']})).rejects.toThrow('Missing required positional argument') + await expect(runCommand(get, {rawArgs: []})).rejects.toThrow('Missing required positional argument') expect(fetchMock).not.toHaveBeenCalled() expect(emit).not.toHaveBeenCalled() @@ -201,7 +203,7 @@ describe('components get', () => { it.each([404, 500])('reports HTTP %s without emitting output', async status => { fetchMock.mockResolvedValue(new Response('Failed to load documentation', {status})) - await expect(runCommand(component, {rawArgs: ['get', 'Button']})).rejects.toThrow( + await expect(runCommand(get, {rawArgs: ['Button']})).rejects.toThrow( `Failed to fetch documentation for Button: HTTP ${status}`, ) expect(emit).not.toHaveBeenCalled() @@ -211,16 +213,14 @@ describe('components get', () => { const error = new TypeError('fetch failed') fetchMock.mockRejectedValue(error) - await expect(runCommand(component, {rawArgs: ['get', 'Button']})).rejects.toBe(error) + await expect(runCommand(get, {rawArgs: ['Button']})).rejects.toBe(error) expect(emit).not.toHaveBeenCalled() }) it('rejects empty documentation without emitting output', async () => { fetchMock.mockResolvedValue(new Response(' \n')) - await expect(runCommand(component, {rawArgs: ['get', 'Button']})).rejects.toThrow( - 'Documentation for Button is empty', - ) + await expect(runCommand(get, {rawArgs: ['Button']})).rejects.toThrow('Documentation for Button is empty') expect(emit).not.toHaveBeenCalled() }) }) @@ -259,7 +259,7 @@ describe('components examples get', () => { ), ) - await runCommand(component, {rawArgs: ['examples', 'get', identifier]}) + await runCommand(getExamples, {rawArgs: [identifier]}) expect(fetchMock).toHaveBeenCalledExactlyOnceWith(new URL(`/product/components/${slug}`, 'https://primer.style'), { signal: expect.any(AbortSignal), @@ -270,16 +270,14 @@ describe('components examples get', () => { }) it('requires a component ID or name', async () => { - await expect(runCommand(component, {rawArgs: ['examples', 'get']})).rejects.toThrow( - 'Missing required positional argument', - ) + await expect(runCommand(getExamples, {rawArgs: []})).rejects.toThrow('Missing required positional argument') expect(fetchMock).not.toHaveBeenCalled() expect(emit).not.toHaveBeenCalled() }) it('rejects unknown components without fetching or emitting output', async () => { - await expect(runCommand(component, {rawArgs: ['examples', 'get', 'UnknownComponent']})).rejects.toThrow( + await expect(runCommand(getExamples, {rawArgs: ['UnknownComponent']})).rejects.toThrow( 'No component found for "UnknownComponent". Use "primer components list" to see available components.', ) @@ -290,7 +288,7 @@ describe('components examples get', () => { it('reports HTTP errors without emitting output', async () => { fetchMock.mockResolvedValue(new Response('Not found', {status: 404})) - await expect(runCommand(component, {rawArgs: ['examples', 'get', 'Button']})).rejects.toThrow( + await expect(runCommand(getExamples, {rawArgs: ['Button']})).rejects.toThrow( 'Failed to fetch documentation for Button: HTTP 404', ) expect(emit).not.toHaveBeenCalled() @@ -300,7 +298,7 @@ describe('components examples get', () => { const error = new TypeError('fetch failed') fetchMock.mockRejectedValue(error) - await expect(runCommand(component, {rawArgs: ['examples', 'get', 'Button']})).rejects.toBe(error) + await expect(runCommand(getExamples, {rawArgs: ['Button']})).rejects.toBe(error) expect(emit).not.toHaveBeenCalled() }) @@ -309,9 +307,7 @@ describe('components examples get', () => { async source => { fetchMock.mockResolvedValue(new Response(source)) - await expect(runCommand(component, {rawArgs: ['examples', 'get', 'Button']})).rejects.toThrow( - /Documentation for Button is/, - ) + await expect(runCommand(getExamples, {rawArgs: ['Button']})).rejects.toThrow(/Documentation for Button is/) expect(emit).not.toHaveBeenCalled() }, ) diff --git a/packages/cli/src/commands/components/guidelines.test.ts b/packages/cli/src/commands/components/guidelines.test.ts index 4ebd34547f9..ed2fe684ceb 100644 --- a/packages/cli/src/commands/components/guidelines.test.ts +++ b/packages/cli/src/commands/components/guidelines.test.ts @@ -1,14 +1,15 @@ import {runCommand} from 'citty' import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {components as component} from './index' +import {get as getUsage} from './usage/get' +import {get as getAccessibility} from './accessibility/get' import {log} from '../../console' vi.mock('../../console') describe.each([ - {kind: 'usage', path: 'guidelines'}, - {kind: 'accessibility', path: 'accessibility'}, -])('components $kind get', ({kind, path}) => { + {kind: 'usage', path: 'guidelines', command: getUsage}, + {kind: 'accessibility', path: 'accessibility', command: getAccessibility}, +])('components $kind get', ({kind, path, command}) => { const emit = vi.mocked(log) const fetchMock = vi.fn() const html = String.raw @@ -40,7 +41,7 @@ describe.each([ ), ) - await runCommand(component, {rawArgs: [kind, 'get', identifier]}) + await runCommand(command, {rawArgs: [identifier]}) expect(fetchMock).toHaveBeenCalledExactlyOnceWith( new URL(`/product/components/${slug}/${path}`, 'https://primer.style'), @@ -52,16 +53,14 @@ describe.each([ }) it('requires a component ID or name', async () => { - await expect(runCommand(component, {rawArgs: [kind, 'get']})).rejects.toThrow( - 'Missing required positional argument', - ) + await expect(runCommand(command, {rawArgs: []})).rejects.toThrow('Missing required positional argument') expect(fetchMock).not.toHaveBeenCalled() expect(emit).not.toHaveBeenCalled() }) it('rejects unknown components without fetching or emitting output', async () => { - await expect(runCommand(component, {rawArgs: [kind, 'get', 'UnknownComponent']})).rejects.toThrow( + await expect(runCommand(command, {rawArgs: ['UnknownComponent']})).rejects.toThrow( 'No component found for "UnknownComponent". Use "primer components list" to see available components.', ) @@ -72,7 +71,7 @@ describe.each([ it('explicitly reports unavailable guidelines on HTTP 404', async () => { fetchMock.mockResolvedValue(new Response('Not found', {status: 404})) - await runCommand(component, {rawArgs: [kind, 'get', 'Button']}) + await runCommand(command, {rawArgs: ['Button']}) expect(emit).toHaveBeenCalledExactlyOnceWith( `There are no ${kind} guidelines for the \`Button\` component in the @primer/react package.`, @@ -82,7 +81,7 @@ describe.each([ it.each([302, 401, 403, 500])('fails on HTTP %s instead of reporting missing guidelines', async status => { fetchMock.mockResolvedValue(new Response('Request failed', {status})) - await expect(runCommand(component, {rawArgs: [kind, 'get', 'Button']})).rejects.toThrow( + await expect(runCommand(command, {rawArgs: ['Button']})).rejects.toThrow( `Failed to fetch documentation for Button: HTTP ${status}`, ) expect(emit).not.toHaveBeenCalled() @@ -92,14 +91,14 @@ describe.each([ const error = new TypeError('fetch failed') fetchMock.mockRejectedValue(error) - await expect(runCommand(component, {rawArgs: [kind, 'get', 'Button']})).rejects.toBe(error) + await expect(runCommand(command, {rawArgs: ['Button']})).rejects.toBe(error) expect(emit).not.toHaveBeenCalled() }) it('fails if the guidelines page has no main content', async () => { fetchMock.mockResolvedValue(new Response(html`

Missing documentation

`)) - await expect(runCommand(component, {rawArgs: [kind, 'get', 'Button']})).rejects.toThrow( + await expect(runCommand(command, {rawArgs: ['Button']})).rejects.toThrow( 'Documentation for Button is missing its main content', ) expect(emit).not.toHaveBeenCalled() diff --git a/packages/cli/src/commands/guidelines/guidelines.test.ts b/packages/cli/src/commands/guidelines/guidelines.test.ts index e7644f2f8ac..b30126e5849 100644 --- a/packages/cli/src/commands/guidelines/guidelines.test.ts +++ b/packages/cli/src/commands/guidelines/guidelines.test.ts @@ -1,6 +1,8 @@ import {runCommand} from 'citty' import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {guidelines} from './index' +import {get as getColor} from './color/get' +import {get as getTypography} from './typography/get' +import {get as getCoding} from './coding/get' import {log} from '../../console' vi.mock('../../console') @@ -20,9 +22,9 @@ afterEach(() => { }) describe.each([ - {kind: 'color', path: 'color-usage'}, - {kind: 'typography', path: 'typography'}, -])('guidelines $kind get', ({kind, path}) => { + {kind: 'color', path: 'color-usage', command: getColor}, + {kind: 'typography', path: 'typography', command: getTypography}, +])('guidelines $kind get', ({kind, path, command}) => { it('fetches the official foundation page and emits its main content as Markdown', async () => { fetchMock.mockResolvedValue( new Response( @@ -35,7 +37,7 @@ describe.each([ ), ) - await runCommand(guidelines, {rawArgs: [kind, 'get']}) + await runCommand(command, {rawArgs: []}) expect(fetchMock).toHaveBeenCalledExactlyOnceWith( new URL(`/product/getting-started/foundations/${path}`, 'https://primer.style'), @@ -49,7 +51,7 @@ describe.each([ it.each([404, 500])('reports HTTP %s without emitting output', async status => { fetchMock.mockResolvedValue(new Response('Failed to load documentation', {status})) - await expect(runCommand(guidelines, {rawArgs: [kind, 'get']})).rejects.toThrow( + await expect(runCommand(command, {rawArgs: []})).rejects.toThrow( `Failed to fetch documentation for ${kind} usage: HTTP ${status}`, ) expect(emit).not.toHaveBeenCalled() @@ -59,7 +61,7 @@ describe.each([ const error = new TypeError('fetch failed') fetchMock.mockRejectedValue(error) - await expect(runCommand(guidelines, {rawArgs: [kind, 'get']})).rejects.toBe(error) + await expect(runCommand(command, {rawArgs: []})).rejects.toBe(error) expect(emit).not.toHaveBeenCalled() }) @@ -68,9 +70,7 @@ describe.each([ async source => { fetchMock.mockResolvedValue(new Response(source)) - await expect(runCommand(guidelines, {rawArgs: [kind, 'get']})).rejects.toThrow( - `Documentation for ${kind} usage is`, - ) + await expect(runCommand(command, {rawArgs: []})).rejects.toThrow(`Documentation for ${kind} usage is`) expect(emit).not.toHaveBeenCalled() }, ) @@ -78,7 +78,7 @@ describe.each([ describe('guidelines coding get', () => { it('emits the Primer coding guidance locally with supported CLI references', async () => { - await runCommand(guidelines, {rawArgs: ['coding', 'get']}) + await runCommand(getCoding, {rawArgs: []}) expect(fetchMock).not.toHaveBeenCalled() expect(emit).toHaveBeenCalledTimes(1) diff --git a/packages/cli/src/commands/patterns/patterns.test.ts b/packages/cli/src/commands/patterns/patterns.test.ts index c95cea399c4..0948cf6d9ef 100644 --- a/packages/cli/src/commands/patterns/patterns.test.ts +++ b/packages/cli/src/commands/patterns/patterns.test.ts @@ -1,15 +1,24 @@ -import {runCommand} from 'citty' +import {runCommand, type ArgsDef, type CommandDef} from 'citty' import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {patterns as patternsCommand} from './index' -import {scenarios as scenariosCommand} from '../scenarios' +import {list as listPatterns} from './list' +import {get as getPattern} from './get' +import {list as listScenarios} from '../scenarios/list' +import {get as getScenario} from '../scenarios/get' import {patterns, scenarios} from '../../pattern-metadata' import {log} from '../../console' vi.mock('../../console') +function commandRunner(command: CommandDef) { + return (rawArgs: Array) => { + return runCommand(command, {rawArgs}) + } +} + describe.each([ { - command: patternsCommand, + runList: commandRunner(listPatterns), + runGet: commandRunner(getPattern), kind: 'patterns', label: 'pattern', path: 'ui-patterns', @@ -20,7 +29,8 @@ describe.each([ wrongCategory: 'create', }, { - command: scenariosCommand, + runList: commandRunner(listScenarios), + runGet: commandRunner(getScenario), kind: 'scenarios', label: 'scenario', path: 'scenario-patterns', @@ -30,7 +40,7 @@ describe.each([ id: 'create', wrongCategory: 'data-visualization', }, -])('$kind commands', ({command, kind, label, path, entries, identifiers, name, id, wrongCategory}) => { +])('$kind commands', ({runList, runGet, kind, label, path, entries, identifiers, name, id, wrongCategory}) => { const emit = vi.mocked(log) const fetchMock = vi.fn() const html = String.raw @@ -46,7 +56,7 @@ describe.each([ }) it('lists only its own catalog in an aligned Markdown table without fetching', async () => { - await runCommand(command, {rawArgs: ['list']}) + await runList([]) expect(fetchMock).not.toHaveBeenCalled() expect(emit).toHaveBeenCalledTimes(1) @@ -67,7 +77,7 @@ describe.each([ }) it('lists IDs and names as a JSON array without pagination', async () => { - await runCommand(command, {rawArgs: ['list', '--json']}) + await runList(['--json']) expect(emit).toHaveBeenCalledExactlyOnceWith(JSON.stringify(entries, null, 2)) }) @@ -83,7 +93,7 @@ describe.each([ limit: 4, }, ])('includes the correct JSON envelope and page info for $flags', async ({flags, page, limit}) => { - await runCommand(command, {rawArgs: ['list', '--json', ...flags]}) + await runList(['--json', ...flags]) expect(emit).toHaveBeenCalledExactlyOnceWith( JSON.stringify( @@ -98,7 +108,7 @@ describe.each([ }) it('includes a page summary below paginated Markdown', async () => { - await runCommand(command, {rawArgs: ['list', '--limit', '1', '--page', '2']}) + await runList(['--limit', '1', '--page', '2']) expect(emit).toHaveBeenCalledTimes(1) const lines = emit.mock.calls[0][0].split('\n') @@ -108,7 +118,7 @@ describe.each([ }) it('retains Markdown headers and page info for an empty page', async () => { - await runCommand(command, {rawArgs: ['list', '--page', '2']}) + await runList(['--page', '2']) expect(emit).toHaveBeenCalledExactlyOnceWith( `| ID | Name |\n| --- | --- |\n\nPage 2 of 1 (${entries.length} ${kind})`, @@ -116,16 +126,14 @@ describe.each([ }) it.each(['limit', 'page'])('rejects invalid --%s without emitting output', async flag => { - await expect(runCommand(command, {rawArgs: ['list', `--${flag}`, '0']})).rejects.toThrow( - `--${flag} must be a positive safe integer`, - ) + await expect(runList([`--${flag}`, '0'])).rejects.toThrow(`--${flag} must be a positive safe integer`) expect(emit).not.toHaveBeenCalled() }) it.each(identifiers)('gets "%s" from its own documentation route', async identifier => { fetchMock.mockResolvedValue(new Response('Guidelines\n----------\n\nUse **Primer**.\n')) - await runCommand(command, {rawArgs: ['get', identifier]}) + await runGet([identifier]) expect(fetchMock).toHaveBeenCalledExactlyOnceWith( new URL(`/product/${path}/${id}/llms.txt`, 'https://primer.style'), @@ -148,7 +156,7 @@ describe.each([ ), ) - await runCommand(command, {rawArgs: ['get', id]}) + await runGet([id]) expect(fetchMock).toHaveBeenCalledTimes(2) expect(fetchMock).toHaveBeenNthCalledWith(1, new URL(`/product/${path}/${id}/llms.txt`, 'https://primer.style'), { @@ -163,14 +171,14 @@ describe.each([ }) it('requires an ID or name', async () => { - await expect(runCommand(command, {rawArgs: ['get']})).rejects.toThrow('Missing required positional argument') + await expect(runGet([])).rejects.toThrow('Missing required positional argument') expect(fetchMock).not.toHaveBeenCalled() expect(emit).not.toHaveBeenCalled() }) it.each(['unknown', wrongCategory])('rejects "%s" instead of searching the other category', async identifier => { - await expect(runCommand(command, {rawArgs: ['get', identifier]})).rejects.toThrow( + await expect(runGet([identifier])).rejects.toThrow( `No ${label} found for "${identifier}". Use "primer ${kind} list" to see available ${kind}.`, ) expect(fetchMock).not.toHaveBeenCalled() @@ -180,9 +188,7 @@ describe.each([ it.each([401, 403, 500])('reports HTTP %s without falling back or emitting output', async status => { fetchMock.mockResolvedValue(new Response('Request failed', {status})) - await expect(runCommand(command, {rawArgs: ['get', id]})).rejects.toThrow( - `Failed to fetch documentation for ${name}: HTTP ${status}`, - ) + await expect(runGet([id])).rejects.toThrow(`Failed to fetch documentation for ${name}: HTTP ${status}`) expect(fetchMock).toHaveBeenCalledTimes(1) expect(emit).not.toHaveBeenCalled() }) @@ -190,9 +196,7 @@ describe.each([ it('reports an unavailable HTML fallback rather than emitting output', async () => { fetchMock.mockResolvedValue(new Response('Not found', {status: 404})) - await expect(runCommand(command, {rawArgs: ['get', id]})).rejects.toThrow( - `Failed to fetch documentation for ${name}: HTTP 404`, - ) + await expect(runGet([id])).rejects.toThrow(`Failed to fetch documentation for ${name}: HTTP 404`) expect(fetchMock).toHaveBeenCalledTimes(2) expect(emit).not.toHaveBeenCalled() }) @@ -201,14 +205,14 @@ describe.each([ const error = new TypeError('fetch failed') fetchMock.mockRejectedValue(error) - await expect(runCommand(command, {rawArgs: ['get', id]})).rejects.toBe(error) + await expect(runGet([id])).rejects.toBe(error) expect(emit).not.toHaveBeenCalled() }) it('rejects empty llms.txt content without falling back or emitting output', async () => { fetchMock.mockResolvedValue(new Response(' \n')) - await expect(runCommand(command, {rawArgs: ['get', id]})).rejects.toThrow(`Documentation for ${name} is empty`) + await expect(runGet([id])).rejects.toThrow(`Documentation for ${name} is empty`) expect(fetchMock).toHaveBeenCalledTimes(1) expect(emit).not.toHaveBeenCalled() }) diff --git a/packages/cli/src/commands/tokens/tokens.test.ts b/packages/cli/src/commands/tokens/tokens.test.ts index 117f7c75e5d..5682435194f 100644 --- a/packages/cli/src/commands/tokens/tokens.test.ts +++ b/packages/cli/src/commands/tokens/tokens.test.ts @@ -2,7 +2,12 @@ import {runCommand} from 'citty' import {afterEach, describe, expect, it, vi} from 'vitest' import sizeCoarse from '@primer/primitives/dist/docs/functional/size/size-coarse.json' with {type: 'json'} import light from '@primer/primitives/dist/docs/functional/themes/light.json' with {type: 'json'} -import {tokens as command} from './index' +import {list} from './list' +import {get} from './get' +import {search} from './search' +import {list as listGroups} from './group/list' +import {specs} from './specs' +import {get as getUsage} from './usage/get' import {log} from '../../console' import {getGroupTokens, getToken, loadTokenGuide, tokens} from '../../token-metadata' @@ -19,7 +24,7 @@ afterEach(() => { describe('tokens list', () => { it('lists the complete deduplicated catalog in a Markdown table', async () => { - await runCommand(command, {rawArgs: ['list']}) + await runCommand(list, {rawArgs: []}) const rows = emit.mock.calls[0][0].split('\n').map(line => { return line @@ -45,7 +50,7 @@ describe('tokens list', () => { }) it('returns token values, types, and guidance as JSON', async () => { - await runCommand(command, {rawArgs: ['list', '--json']}) + await runCommand(list, {rawArgs: ['--json']}) expect(JSON.parse(emit.mock.calls[0][0])).toEqual(sorted) expect(JSON.parse(emit.mock.calls[0][0])).toContainEqual( @@ -64,7 +69,7 @@ describe('tokens list', () => { {flags: ['--page', '2'], page: 2, limit: 10}, {flags: ['--limit', '3', '--page', '2'], page: 2, limit: 3}, ])('includes page information with $flags', async ({flags, page, limit}) => { - await runCommand(command, {rawArgs: ['list', '--json', ...flags]}) + await runCommand(list, {rawArgs: ['--json', ...flags]}) expect(JSON.parse(emit.mock.calls[0][0])).toEqual({ tokens: sorted.slice((page - 1) * limit, page * limit), @@ -74,7 +79,7 @@ describe('tokens list', () => { it('retains table headers and page information beyond the last page', async () => { const page = tokens.length + 1 - await runCommand(command, {rawArgs: ['list', '--page', String(page)]}) + await runCommand(list, {rawArgs: ['--page', String(page)]}) expect(emit).toHaveBeenCalledExactlyOnceWith( `| Name | Value | Group |\n| --- | --- | --- |\n\nPage ${page} of ${Math.ceil(tokens.length / 10)} (${tokens.length} tokens)`, @@ -86,7 +91,7 @@ describe('tokens get', () => { it.each(['bgColor-default', 'BGCOLOR-DEFAULT', 'var( --bgColor-default )'])( 'resolves %s to an exact token and includes upstream guidance', async name => { - await runCommand(command, {rawArgs: ['get', name, '--json']}) + await runCommand(get, {rawArgs: [name, '--json']}) expect(JSON.parse(emit.mock.calls[0][0])).toEqual(getToken('bgColor-default')) expect(getToken('bgColor-default').useCase).toContain('card-background') @@ -95,13 +100,13 @@ describe('tokens get', () => { ) it('accepts a CSS custom property following the end-of-options marker', async () => { - await runCommand(command, {rawArgs: ['get', '--json', '--', '--bgColor-default']}) + await runCommand(get, {rawArgs: ['--json', '--', '--bgColor-default']}) expect(JSON.parse(emit.mock.calls[0][0]).name).toBe('bgColor-default') }) it('formats Markdown with value, type, group, usage, and rules', async () => { - await runCommand(command, {rawArgs: ['get', 'bgColor-default']}) + await runCommand(get, {rawArgs: ['bgColor-default']}) const output = emit.mock.calls[0][0] expect(output).toContain('### bgColor-default') @@ -118,13 +123,13 @@ describe('tokens get', () => { {name: 'base-text-weight-light', value: '300'}, {name: 'base-text-lineHeight-loose', value: '1.75'}, ])('serializes structured and numeric values for $name', async ({name, value}) => { - await runCommand(command, {rawArgs: ['get', name, '--json']}) + await runCommand(get, {rawArgs: [name, '--json']}) expect(JSON.parse(emit.mock.calls[0][0]).value).toBe(value) }) it('uses JSON extension guidance where the Markdown spec has no entry', async () => { - await runCommand(command, {rawArgs: ['get', 'base-easing-ease', '--json']}) + await runCommand(get, {rawArgs: ['base-easing-ease', '--json']}) expect(JSON.parse(emit.mock.calls[0][0])).toMatchObject({ useCase: expect.stringContaining('hover-state'), @@ -133,25 +138,25 @@ describe('tokens get', () => { }) it('preserves the MCP source precedence for duplicate coarse/fine tokens', async () => { - await runCommand(command, {rawArgs: ['get', 'control-minTarget-auto', '--json']}) + await runCommand(get, {rawArgs: ['control-minTarget-auto', '--json']}) expect(JSON.parse(emit.mock.calls[0][0]).value).toBe(sizeCoarse['control-minTarget-auto'].value) }) it.each(['unknown-token', 'default'])('rejects unknown or partial names (%s)', async name => { - await expect(runCommand(command, {rawArgs: ['get', name]})).rejects.toThrow(`No token found for "${name}"`) + await expect(runCommand(get, {rawArgs: [name]})).rejects.toThrow(`No token found for "${name}"`) expect(emit).not.toHaveBeenCalled() }) it('requires a token name', async () => { - await expect(runCommand(command, {rawArgs: ['get']})).rejects.toThrow('Missing required positional argument') + await expect(runCommand(get, {rawArgs: []})).rejects.toThrow('Missing required positional argument') expect(emit).not.toHaveBeenCalled() }) }) describe('tokens search', () => { it('defaults to 15 results and includes page information', async () => { - await runCommand(command, {rawArgs: ['search', '--group', 'bgColor', '--json']}) + await runCommand(search, {rawArgs: ['--group', 'bgColor', '--json']}) const matches = getGroupTokens(['bgColor']) expect(matches.length).toBeGreaterThan(15) @@ -162,7 +167,7 @@ describe('tokens search', () => { }) it('supports quoted keyword AND searches and aliases in an explicit group', async () => { - await runCommand(command, {rawArgs: ['search', 'body medium', '--group', 'typography', '--json']}) + await runCommand(search, {rawArgs: ['body medium', '--group', 'typography', '--json']}) const result = JSON.parse(emit.mock.calls[0][0]) expect(result.tokens.length).toBeGreaterThan(0) @@ -175,7 +180,7 @@ describe('tokens search', () => { }) it('extracts group aliases from unquoted words and resolves semantic color intent', async () => { - await runCommand(command, {rawArgs: ['search', 'red', 'bordercolor', '--json']}) + await runCommand(search, {rawArgs: ['red', 'bordercolor', '--json']}) const result = JSON.parse(emit.mock.calls[0][0]) expect(result.tokens.length).toBeGreaterThan(0) @@ -186,20 +191,20 @@ describe('tokens search', () => { }) it('accepts a group alias as the whole query', async () => { - await runCommand(command, {rawArgs: ['search', 'spacing', '--json']}) + await runCommand(search, {rawArgs: ['spacing', '--json']}) expect(JSON.parse(emit.mock.calls[0][0]).tokens).toEqual(getGroupTokens(['stack']).slice(0, 15)) }) it('searches guidance as well as names', async () => { - await runCommand(command, {rawArgs: ['search', 'card-background', '--json']}) + await runCommand(search, {rawArgs: ['card-background', '--json']}) expect(JSON.parse(emit.mock.calls[0][0]).tokens).toContainEqual(getToken('bgColor-default')) }) it('allows another page and an explicit result limit', async () => { - await runCommand(command, { - rawArgs: ['search', '--group', 'background color', '--limit', '2', '--page', '2', '--json'], + await runCommand(search, { + rawArgs: ['--group', 'background color', '--limit', '2', '--page', '2', '--json'], }) const matches = getGroupTokens(['bgColor']) @@ -210,7 +215,7 @@ describe('tokens search', () => { }) it('returns an empty paginated JSON result for no matches', async () => { - await runCommand(command, {rawArgs: ['search', 'danger nonexistent-keyword', '--json']}) + await runCommand(search, {rawArgs: ['danger nonexistent-keyword', '--json']}) expect(JSON.parse(emit.mock.calls[0][0])).toEqual({ tokens: [], @@ -219,7 +224,7 @@ describe('tokens search', () => { }) it('includes usage and rules in Markdown search results', async () => { - await runCommand(command, {rawArgs: ['search', 'card-background']}) + await runCommand(search, {rawArgs: ['card-background']}) const output = emit.mock.calls[0][0] expect(output).toContain('### bgColor-default') @@ -229,7 +234,7 @@ describe('tokens search', () => { }) it('reports no matches explicitly in Markdown', async () => { - await runCommand(command, {rawArgs: ['search', 'nonexistent-keyword']}) + await runCommand(search, {rawArgs: ['nonexistent-keyword']}) expect(emit.mock.calls[0][0]).toContain('No tokens found.') }) @@ -237,7 +242,7 @@ describe('tokens search', () => { it.each([{flags: []}, {flags: ['--group', 'nonexistent-group']}])( 'rejects missing input or unknown groups ($flags)', async ({flags}) => { - await expect(runCommand(command, {rawArgs: ['search', ...flags]})).rejects.toThrow() + await expect(runCommand(search, {rawArgs: [...flags]})).rejects.toThrow() expect(emit).not.toHaveBeenCalled() }, ) @@ -245,7 +250,7 @@ describe('tokens search', () => { it.each(['0', '-1', '1.5', '101', 'invalid', '9007199254740992'])( 'rejects invalid search limits (%s)', async limit => { - await expect(runCommand(command, {rawArgs: ['search', 'spacing', '--limit', limit]})).rejects.toThrow('--limit') + await expect(runCommand(search, {rawArgs: ['spacing', '--limit', limit]})).rejects.toThrow('--limit') expect(emit).not.toHaveBeenCalled() }, ) @@ -253,7 +258,7 @@ describe('tokens search', () => { describe('tokens group list', () => { it('bundles multiple groups without duplicates and includes group usage hints', async () => { - await runCommand(command, {rawArgs: ['group', 'list', 'typography', 'text', 'background', '--json']}) + await runCommand(listGroups, {rawArgs: ['typography', 'text', 'background', '--json']}) expect(JSON.parse(emit.mock.calls[0][0])).toEqual({ tokens: getGroupTokens(['text', 'bgColor']), @@ -263,7 +268,7 @@ describe('tokens group list', () => { }) it('paginates JSON bundles with 10 items by default', async () => { - await runCommand(command, {rawArgs: ['group', 'list', 'control', 'button', '--paginate', '--json']}) + await runCommand(listGroups, {rawArgs: ['control', 'button', '--paginate', '--json']}) const matches = getGroupTokens(['control', 'button']) expect(JSON.parse(emit.mock.calls[0][0])).toMatchObject({ @@ -273,7 +278,7 @@ describe('tokens group list', () => { }) it('emits Markdown bundles with value, rules, and usage hints', async () => { - await runCommand(command, {rawArgs: ['group', 'list', 'control', 'focus', '--limit', '2']}) + await runCommand(listGroups, {rawArgs: ['control', 'focus', '--limit', '2']}) const output = emit.mock.calls[0][0] expect(output).toContain('**Value:**') @@ -286,7 +291,7 @@ describe('tokens group list', () => { it.each([{groups: []}, {groups: ['control', 'unknown-group']}])( 'rejects missing or unknown groups ($groups)', async ({groups}) => { - await expect(runCommand(command, {rawArgs: ['group', 'list', ...groups]})).rejects.toThrow() + await expect(runCommand(listGroups, {rawArgs: [...groups]})).rejects.toThrow() expect(emit).not.toHaveBeenCalled() }, ) @@ -294,7 +299,7 @@ describe('tokens group list', () => { describe('token specifications and usage', () => { it('includes current groups, semantic mapping, recipes, and the full upstream guide', async () => { - await runCommand(command, {rawArgs: ['specs']}) + await runCommand(specs, {rawArgs: []}) const output = emit.mock.calls[0][0] expect(output).toContain('# Design Token Specifications') @@ -307,7 +312,7 @@ describe('token specifications and usage', () => { }) it('includes Button/Stack examples and the upstream Golden Example', async () => { - await runCommand(command, {rawArgs: ['usage', 'get']}) + await runCommand(getUsage, {rawArgs: []}) const output = emit.mock.calls[0][0] expect(output).toContain('## Interaction Pattern: Button') diff --git a/packages/cli/src/routing.test.ts b/packages/cli/src/routing.test.ts new file mode 100644 index 00000000000..87ee4868bf5 --- /dev/null +++ b/packages/cli/src/routing.test.ts @@ -0,0 +1,147 @@ +import {runCommand, runMain} from 'citty' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {list as listComponents} from './commands/components/list' +import {get as getComponent} from './commands/components/get' +import {get as getExamples} from './commands/components/examples/get' +import {get as getUsage} from './commands/components/usage/get' +import {get as getAccessibility} from './commands/components/accessibility/get' +import {get as getColor} from './commands/guidelines/color/get' +import {get as getTypography} from './commands/guidelines/typography/get' +import {get as getCoding} from './commands/guidelines/coding/get' +import {list as listPatterns} from './commands/patterns/list' +import {get as getPattern} from './commands/patterns/get' +import {list as listScenarios} from './commands/scenarios/list' +import {get as getScenario} from './commands/scenarios/get' +import {list as listTokens} from './commands/tokens/list' +import {get as getToken} from './commands/tokens/get' +import {search as searchTokens} from './commands/tokens/search' +import {list as listGroups} from './commands/tokens/group/list' +import {specs} from './commands/tokens/specs' +import {get as getTokenUsage} from './commands/tokens/usage/get' +import './index' + +vi.mock(import('citty'), async importOriginal => { + const original = await importOriginal() + return { + ...original, + runMain: vi.fn(), + } +}) + +const [main] = vi.mocked(runMain).mock.calls[0] +const routes = [ + { + path: 'components list', + command: listComponents, + args: ['--json', '--limit', '2'], + }, + { + path: 'components get', + command: getComponent, + args: ['Button'], + }, + { + path: 'components examples get', + command: getExamples, + args: ['Button'], + }, + { + path: 'components usage get', + command: getUsage, + args: ['Button'], + }, + { + path: 'components accessibility get', + command: getAccessibility, + args: ['Button'], + }, + { + path: 'guidelines color get', + command: getColor, + args: [], + }, + { + path: 'guidelines typography get', + command: getTypography, + args: [], + }, + { + path: 'guidelines coding get', + command: getCoding, + args: [], + }, + { + path: 'patterns list', + command: listPatterns, + args: ['--json'], + }, + { + path: 'patterns get', + command: getPattern, + args: ['data-visualization'], + }, + { + path: 'scenarios list', + command: listScenarios, + args: ['--json'], + }, + { + path: 'scenarios get', + command: getScenario, + args: ['create'], + }, + { + path: 'tokens list', + command: listTokens, + args: ['--json'], + }, + { + path: 'tokens get', + command: getToken, + args: ['--json', '--', '--bgColor-default'], + }, + { + path: 'tokens search', + command: searchTokens, + args: ['body medium', '--group', 'typography'], + }, + { + path: 'tokens group list', + command: listGroups, + args: ['control', 'button', '--json'], + }, + { + path: 'tokens specs', + command: specs, + args: [], + }, + { + path: 'tokens usage get', + command: getTokenUsage, + args: [], + }, +] + +describe('CLI routing', () => { + beforeEach(() => { + for (const {command} of routes) { + vi.spyOn(command, 'run').mockImplementation(() => {}) + } + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it.each(routes)('dispatches $path to its leaf command', async ({path, command, args}) => { + await runCommand(main, { + rawArgs: [...path.split(' '), ...args], + }) + + expect(command.run).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + rawArgs: args, + }), + ) + }) +}) From febd1063b9a7c91b08fd4f9f180d400c74b26634 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 7 Oct 2026 15:42:18 -0500 Subject: [PATCH 13/13] Update testing documentation for @primer/cli Clarify testing instructions for @primer/cli and interaction tests. --- contributor-docs/testing.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/contributor-docs/testing.md b/contributor-docs/testing.md index ed41a02ccd2..a31c734a871 100644 --- a/contributor-docs/testing.md +++ b/contributor-docs/testing.md @@ -88,8 +88,6 @@ We are slowly moving away from using snapshots as a way to test visual changes o By default, Vitest fails when tests emit unexpected console output in CI. For local debugging, it is disabled unless you opt in with `VITEST_FAIL_ON_CONSOLE=true`. -For `@primer/cli`, statically import leaf commands in behavior tests and call `runCommand` with only that command's arguments. Keep network requests and output mocked. This preserves argument parsing and validation coverage without counting cold lazy imports against the test timeout. Keep command-tree routing coverage separate, with leaf handlers stubbed so those tests only exercise dispatch. Run the CLI suite with `npm test -- --run --project=@primer/cli`. - ## Interaction Tests ### As A Part Of Unit Tests