diff --git a/package-lock.json b/package-lock.json index e984b5fb181..68c6c21fb0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7027,6 +7027,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, @@ -12331,7 +12335,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": { @@ -12482,6 +12485,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", @@ -28035,6 +28044,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", @@ -30613,6 +30733,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, @@ -30939,6 +31068,31 @@ "url": "https://github.com/sponsors/isaacs" } }, + "packages/cli": { + "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", + "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", + "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..5c5e50485bc --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,46 @@ +{ + "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", + "type-check": "tsc --noEmit", + "watch": "rolldown -c -w" + }, + "dependencies": { + "@primer/primitives": "^11.5.1", + "cheerio": "^1.0.0", + "citty": "^0.2.2", + "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", + "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..d8064e52fe6 --- /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: { + dir: fileURLToPath(new URL('./dist', import.meta.url)), + format: 'esm', + }, +}) 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/components/components.test.ts b/packages/cli/src/commands/components/components.test.ts new file mode 100644 index 00000000000..28309968a50 --- /dev/null +++ b/packages/cli/src/commands/components/components.test.ts @@ -0,0 +1,314 @@ +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 {list} from './list' +import {get} from './get' +import {get as getExamples} from './examples/get' +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('components list', () => { + const emit = vi.mocked(log) + + afterEach(() => { + emit.mockClear() + }) + + it('lists all components in a Markdown table by default', async () => { + await runCommand(list, {rawArgs: []}) + + 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(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(list, {rawArgs: ['--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(list, {rawArgs: ['--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(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)`, + ) + }) + + it('includes page info for an empty Markdown page', async () => { + const page = components.length + 1 + 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)`, + ) + }) + + 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(list, {rawArgs: [`--${flag}=${value}`]})).rejects.toThrow( + `--${flag} must be a positive safe integer`, + ) + } + + await expect(runCommand(list, {rawArgs: [`--${flag}`]})).rejects.toThrow( + `--${flag} must be a positive safe integer`, + ) + expect(emit).not.toHaveBeenCalled() + }) +}) + +describe('components 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(get, {rawArgs: [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(get, {rawArgs: ['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('requires a component ID or name', async () => { + await expect(runCommand(get, {rawArgs: []})).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(get, {rawArgs: ['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(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(get, {rawArgs: ['Button']})).rejects.toThrow('Documentation for Button is empty') + 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(getExamples, {rawArgs: [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(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(getExamples, {rawArgs: ['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(getExamples, {rawArgs: ['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(getExamples, {rawArgs: ['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(getExamples, {rawArgs: ['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..ed2fe684ceb --- /dev/null +++ b/packages/cli/src/commands/components/guidelines.test.ts @@ -0,0 +1,106 @@ +import {runCommand} from 'citty' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +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', 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 + + 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(command, {rawArgs: [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(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(command, {rawArgs: ['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(command, {rawArgs: ['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(command, {rawArgs: ['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(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(command, {rawArgs: ['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..85a456c26e9 --- /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 \`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 + +- 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. 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 + +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..b30126e5849 --- /dev/null +++ b/packages/cli/src/commands/guidelines/guidelines.test.ts @@ -0,0 +1,102 @@ +import {runCommand} from 'citty' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +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') + +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', 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( + html` +
+

Usage

+

Use design tokens.

+
+
Footer
`, + ), + ) + + await runCommand(command, {rawArgs: []}) + + 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(command, {rawArgs: []})).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(command, {rawArgs: []})).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(command, {rawArgs: []})).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(getCoding, {rawArgs: []}) + + 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 scenarios list`') + 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/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/icons.test.ts b/packages/cli/src/commands/icons.test.ts new file mode 100644 index 00000000000..b0d7b06eebf --- /dev/null +++ b/packages/cli/src/commands/icons.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 {icons as icon} from './icons' +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('icons 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('icons 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 icons 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/commands/icons.ts b/packages/cli/src/commands/icons.ts new file mode 100644 index 00000000000..ec2501db166 --- /dev/null +++ b/packages/cli/src/commands/icons.ts @@ -0,0 +1,114 @@ +import {defineCommand} from 'citty' +import {tablemark} from 'tablemark' +import octicons from '@primer/octicons/build/data.json' with {type: 'json'} +import {log} from '../console' +import {fetchMarkdown} from '../documentation' +import {paginate} from '../pagination' + +const availableIcons = 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 = availableIcons.find(icon => { + return icon.name.toLowerCase() === args.name.toLowerCase() + }) + if (!match) { + 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( + `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 documentation = await fetchMarkdown(url, match.name) + + 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(availableIcons, 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 icons = defineCommand({ + meta: { + name: 'icons', + description: 'Explore Primer Octicons', + }, + subCommands: { + get, + list, + }, +}) diff --git a/packages/cli/src/commands/patterns/get.ts b/packages/cli/src/commands/patterns/get.ts new file mode 100644 index 00000000000..21303f4e601 --- /dev/null +++ b/packages/cli/src/commands/patterns/get.ts @@ -0,0 +1,25 @@ +import {defineCommand} from 'citty' +import {log} from '../../console' +import {fetchPreferredMarkdown} 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 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/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..0948cf6d9ef --- /dev/null +++ b/packages/cli/src/commands/patterns/patterns.test.ts @@ -0,0 +1,219 @@ +import {runCommand, type ArgsDef, type CommandDef} from 'citty' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +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([ + { + runList: commandRunner(listPatterns), + runGet: commandRunner(getPattern), + kind: 'patterns', + label: 'pattern', + path: 'ui-patterns', + entries: patterns, + identifiers: ['data-visualization', 'Data Visualization', 'DATA VISUALIZATION'], + name: 'Data Visualization', + id: 'data-visualization', + wrongCategory: 'create', + }, + { + runList: commandRunner(listScenarios), + runGet: commandRunner(getScenario), + kind: 'scenarios', + label: 'scenario', + path: 'scenario-patterns', + entries: scenarios, + identifiers: ['create', 'Create', 'CREATE'], + name: 'Create', + id: 'create', + wrongCategory: 'data-visualization', + }, +])('$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 + + 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 runList([]) + + 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 runList(['--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 runList(['--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 runList(['--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 runList(['--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(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 runGet([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` +
+

Guidelines

+

Use Primer.

+
+
Footer
`, + ), + ) + + await runGet([id]) + + 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( + `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(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(runGet([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([401, 403, 500])('reports HTTP %s without falling back or emitting output', async status => { + fetchMock.mockResolvedValue(new Response('Request failed', {status})) + + await expect(runGet([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(runGet([id])).rejects.toThrow(`Failed to fetch documentation for ${name}: HTTP 404`) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(emit).not.toHaveBeenCalled() + }) + + it('propagates network errors without emitting output', async () => { + const error = new TypeError('fetch failed') + fetchMock.mockRejectedValue(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(runGet([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 new file mode 100644 index 00000000000..a9322b32cda --- /dev/null +++ b/packages/cli/src/commands/scenarios/get.ts @@ -0,0 +1,25 @@ +import {defineCommand} from 'citty' +import {log} from '../../console' +import {fetchPreferredMarkdown} 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 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/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/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..5682435194f --- /dev/null +++ b/packages/cli/src/commands/tokens/tokens.test.ts @@ -0,0 +1,329 @@ +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 {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' + +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(list, {rawArgs: []}) + + 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(list, {rawArgs: ['--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(list, {rawArgs: ['--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(list, {rawArgs: ['--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(get, {rawArgs: [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(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(get, {rawArgs: ['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(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(get, {rawArgs: ['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(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(get, {rawArgs: [name]})).rejects.toThrow(`No token found for "${name}"`) + expect(emit).not.toHaveBeenCalled() + }) + + it('requires a token name', async () => { + 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(search, {rawArgs: ['--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(search, {rawArgs: ['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(search, {rawArgs: ['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(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(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(search, { + rawArgs: ['--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(search, {rawArgs: ['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(search, {rawArgs: ['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(search, {rawArgs: ['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(search, {rawArgs: [...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(search, {rawArgs: ['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(listGroups, {rawArgs: ['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(listGroups, {rawArgs: ['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(listGroups, {rawArgs: ['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(listGroups, {rawArgs: [...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(specs, {rawArgs: []}) + + 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(getUsage, {rawArgs: []}) + + 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/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/documentation.ts b/packages/cli/src/documentation.ts new file mode 100644 index 00000000000..de7968ca80d --- /dev/null +++ b/packages/cli/src/documentation.ts @@ -0,0 +1,53 @@ +// 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 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) { + 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 new file mode 100644 index 00000000000..ceb99834b1c --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,38 @@ +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, + }, + subCommands: { + async components() { + const mod = await import('./commands/components') + return mod.components + }, + async guidelines() { + const mod = await import('./commands/guidelines') + return mod.guidelines + }, + async icons() { + 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 + }, + async tokens() { + const mod = await import('./commands/tokens') + return mod.tokens + }, + }, +}) + +await runMain(main) 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, + } +} 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 +} 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, + }), + ) + }) +}) 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 +} 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, + }, +}) diff --git a/skills/cli/SKILL.md b/skills/cli/SKILL.md new file mode 100644 index 00000000000..9a0405d7fc3 --- /dev/null +++ b/skills/cli/SKILL.md @@ -0,0 +1,9 @@ +--- +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 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.