diff --git a/.github/skills/curate-carbon-component/SKILL.md b/.github/skills/curate-carbon-component/SKILL.md new file mode 100644 index 0000000000..850c40bb7f --- /dev/null +++ b/.github/skills/curate-carbon-component/SKILL.md @@ -0,0 +1,49 @@ +--- +name: curate-carbon-component +description: Curate or review one Carbon React component's authored skill metadata and agent-facing Storybook examples. Use when extending component skill coverage or changing docs/component-metadata; do not use for ordinary component implementation alone. +--- + +# Curate a Carbon component + +Read `docs/component-metadata/README.md` before making changes. It defines the +source-of-truth split, metadata fields, example criteria, and completion checks. + +Keep the task to one active component unless the user requests a wider scope. If +a component has not been chosen, run `npm run skills:status` and select from the +active components awaiting review; do not spend coverage effort on deprecated +components unless migration guidance is specifically needed. + +Inspect the component source, its Storybook stories and MDX, and its current +generated component file before authoring metadata. Treat Carbon source and +Storybook as the primary developer-facing sources. Fusion guidance may provide +useful supplementary design context when available, but it is not a prerequisite +and must not override Carbon's documented behaviour. Do not infer product +guidance from prop names or component behaviour. Add only guidance that changes +an agent's decisions. Omit optional fields instead of repeating the summary or +filling a template. + +Keep capability and design decisions separate. Explain what props and visual +variants enable, plus any technical constraints, without prescribing when a +designer should choose them. Prescriptive guidance belongs here only when it is +needed for component selection, accessibility, implementation correctness, or a +documented Carbon requirement. + +Curate a small set of focused, consumer-useful stories. Do not curate playground +stories or examples using deprecated APIs. Preserve the Storybook patterns the +project wants to use; improve the extractor or source story when necessary +rather than rewriting stories merely for extraction. If a selected example is +incomplete or inaccessible, correct the source story so Storybook and the skill +remain aligned. + +Never edit `skills/carbon-react/` directly. Run `npm run build:skills`, inspect +the generated component and every linked example, then run: + +```shell +npm run test:skills +npm run build:skills -- --check +npm run type-check +``` + +Finish only when the generated guidance is concise, imports and props are +current, deprecations are represented accurately, examples are understandable +without Storybook context, and the checks pass. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19cebe0bda..0321192a1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,9 @@ jobs: - name: Ensure the Carbon skills are up to date (run npm run build:skills locally to fix this error) run: npm run build:skills -- --check + - name: Test the Carbon skill metadata validation + run: npm run test:skills + test: name: Test with Node ${{ matrix.node }} (${{ matrix.shard }}, 4) runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7be81606d..49bcf12112 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,16 +27,25 @@ More details about how we test can be found in our [Testing Guide](contributing/ ## Keeping skills up to date -Updating skills is a required part of the contribution process. When making changes that affect a component's API, props, behaviour, usage patterns or documentation the corresponding skill file(s) in `skills/` **must** be updated to reflect those changes. +Files in `skills/carbon-react/` are generated and must not be edited directly. When making changes that affect a component's API, props, behaviour, usage patterns or documentation, regenerate them with: -Failure to keep skills in sync with the codebase will result in a **lint failure**, blocking your pull request from being merged. +```shell +npm run build:skills +``` + +To add authored component guidance or curate Storybook examples for agent use, follow the [component skill metadata guide](docs/component-metadata/README.md). Add only information that changes an agent's decisions; source-derived API details remain owned by the component code. -When updating a component, update its skills file alongside your code changes in the same pull request. In many cases, running the build command will automatically generate the required skill changes for you: +Copilot users can ask the agent to use the `curate-carbon-component` skill for this workflow. + +Before raising a pull request, run: ```shell -npm run build:skills +npm run test:skills +npm run build:skills -- --check ``` +CI runs both commands and fails if metadata is invalid or generated files have drifted. + ## Bugs ### Where to find known issues diff --git a/docs/component-metadata/README.md b/docs/component-metadata/README.md new file mode 100644 index 0000000000..28d1b8f30a --- /dev/null +++ b/docs/component-metadata/README.md @@ -0,0 +1,85 @@ +# Component skill metadata + +This directory contains the human-authored part of the Carbon React skill. One +JSON file represents one component that has been reviewed for agent use. The +absence of a file means that the component still uses source-generated API data +and uncurated Storybook examples; it does not mean that placeholder guidance +should be added. + +## Sources of truth + +| Information | Source | +| ---------------------------------------------------------------- | ------------------------------------------------------ | +| Public exports, imports, props, types, defaults and deprecations | Component source, read by the generator | +| Developer usage guidance | Carbon Storybook MDX and stories | +| Supplementary design context | Fusion guidance, when available and relevant | +| Curated decision guidance, pitfalls and example selection | `docs/component-metadata/.json` | +| Runnable example implementation | The selected Storybook story | +| Agent-facing Markdown | `skills/carbon-react/`, generated; never edit directly | + +This split keeps facts that can silently drift tied to code while allowing +maintainers to author product guidance that cannot be inferred reliably. + +## Add or review a component + +1. Create a kebab-case JSON file matching the generated component name. Use + `pill.json` as the structural example, but assess the component independently. +2. Inspect Carbon's Storybook MDX and stories as the primary developer guidance. + Consult Fusion for supplementary design context when it is available and + relevant, but do not make curation depend on access to it or let it override + Carbon's documented behaviour. +3. Write a concise `summary` that distinguishes the component. Add optional + sections only when they change an agent's decision; do not repeat the summary + in `useWhen` or add text merely to fill a section. +4. Describe what props and visual variants enable without deciding when a + designer should choose them. Include prescriptive guidance only for component + selection, accessibility, implementation correctness, or an explicit Carbon + requirement. +5. Select only stories that demonstrate useful consumer behaviour. Playground + stories are interactive documentation and should not be curated by default. + A selected story must resolve to public consumer imports after generation, + avoid deprecated APIs, and produce a focused example that makes sense outside + Storybook. +6. Improve the source story when the example itself is incomplete, inaccessible, + or uses an obsolete pattern. Do not compensate by hand-editing generated + Markdown. +7. Run `npm run build:skills`, then review the component entry and every generated + example as an agent-facing consumer would. + +Supported fields: + +| Field | Required | Purpose | +| -------------- | -------- | --------------------------------------------------------------------- | +| `component` | Yes | Exact generated component name, such as `Pill` or `ButtonNext`. | +| `summary` | Yes | One sentence explaining the component's distinct purpose. | +| `useWhen` | No | Situations not already obvious from the summary. | +| `avoidWhen` | No | Important boundaries that are clearer without naming one alternative. | +| `alternatives` | No | A non-deprecated Carbon component and when to choose it instead. | +| `pitfalls` | No | Non-obvious accessibility, composition or implementation risks. | +| `examples` | No | Story export names and descriptions explaining why each is useful. | + +Omit an optional field when there is no useful content. If supplied, list fields +must be non-empty. The generator rejects unknown fields, invalid component or +story references, deprecated alternatives, deprecated APIs in curated examples, +and curated examples that retain source-relative imports. + +## Completion checklist + +- The metadata adds decision-making value and contains no duplicated filler. +- Guidance is grounded in Carbon documentation and behaviour rather than + inferred from the component name or API shape. +- Visual variant guidance explains capability without making contextual design + decisions for the consumer. +- Alternatives and selected stories use current, public APIs. +- Curated examples are focused, accessible, syntactically valid, and readable + without interpreting Storybook composition. +- The generated component file has accurate imports, props, defaults and + deprecation details. +- `npm run test:skills`, `npm run build:skills -- --check`, and + `npm run type-check` pass. + +Run `npm run skills:status` to list reviewed components and those still awaiting +authored metadata. Deprecated components are reported separately and do not need +authored metadata unless useful migration guidance is missing. Coverage is +intentionally incremental; CI checks correctness and generated-file drift rather +than requiring filler for every component. diff --git a/docs/component-metadata/pill.json b/docs/component-metadata/pill.json new file mode 100644 index 0000000000..675d9c7a77 --- /dev/null +++ b/docs/component-metadata/pill.json @@ -0,0 +1,34 @@ +{ + "component": "Pill", + "summary": "A compact visual indicator that highlights information or status.", + "useWhen": [ + "An item's status needs to be prominent at a glance; this is Pill's primary use.", + "A short label helps categorise an item; this is secondary to communicating status." + ], + "alternatives": [ + { + "component": "ButtonNext", + "when": "The element's primary purpose is to perform an action rather than display information or status." + } + ], + "pitfalls": [ + "Give a removable pill a clear, unique accessible label that describes the relationship between the pill and its removal button.", + "Use a left icon only with size L.", + "Keep the Pill itself out of the tab order; only its removal button should receive keyboard focus.", + "Add visually hidden context when the pill's meaning is not clear from its visible label and surroundings." + ], + "examples": [ + { + "story": "Wrapped", + "description": "Allow an unusually long label to wrap within a constrained width." + }, + { + "story": "WithRemoveButton", + "description": "Make a pill removable with onDelete and give its removal button a clear, contextual accessible label." + }, + { + "story": "InverseOnDarkBackground", + "description": "Apply inverse styling to a pill." + } + ] +} diff --git a/package.json b/package.json index a4773e95c4..1bd1ba5ba7 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "build:generate-package-json-files": "node ./scripts/generate_package_json_files/index.js", "build:move-svg": "node ./scripts/copy_svg/index.js", "build:skills": "node ./scripts/skills/build_skills.mjs", + "skills:status": "node ./scripts/skills/build_skills.mjs --metadata-status", + "test:skills": "node --test ./scripts/skills/*.test.mjs", "generate-tokens": "rimraf ./src/components/tokens-wrapper/static-tokens && node ./scripts/generate_tokens/generate_tokens.mjs --include-dark" }, "repository": { diff --git a/scripts/skills/build_skills.mjs b/scripts/skills/build_skills.mjs index 85f7c14608..a90ee91cd7 100644 --- a/scripts/skills/build_skills.mjs +++ b/scripts/skills/build_skills.mjs @@ -5,6 +5,7 @@ import { existsSync, statSync } from "node:fs"; import { fileURLToPath } from "node:url"; import fg from "fast-glob"; import { JSDoc, Project, SyntaxKind } from "ts-morph"; +import { loadComponentMetadata } from "./component-metadata.mjs"; /** * @typedef {Object} ComponentCandidate @@ -28,6 +29,7 @@ import { JSDoc, Project, SyntaxKind } from "ts-morph"; * @property {string | null} description * @property {string | null} defaultValue * @property {boolean} deprecated + * @property {Array} deprecatedValues * @property {string | null} deprecationReason */ @@ -46,9 +48,11 @@ import { JSDoc, Project, SyntaxKind } from "ts-morph"; /** * @typedef {Object} StoryEntry + * @property {string} exportName * @property {string} name * @property {string | null} argsText * @property {string | null} renderText + * @property {string | null} importsText * @property {"csf" | "mdx"} kind * @property {string} [source] */ @@ -65,11 +69,14 @@ const repoRoot = path.resolve(__dirname, "../.."); const packageName = "carbon-react"; const checkMode = process.argv.includes("--check"); +const metadataStatusMode = process.argv.includes("--metadata-status"); const buildOutputFolder = "lib"; const indexFilePath = path.join(repoRoot, "src", "index.ts"); const skillsRoot = path.join(repoRoot, "skills", "carbon-react"); const componentsOutDir = path.join(skillsRoot, "components"); +const examplesOutDir = path.join(skillsRoot, "examples"); const referencesDir = path.join(skillsRoot, "references", "docs"); +const componentMetadataDir = path.join(repoRoot, "docs", "component-metadata"); /** @type {string[]} */ const docsReferenceFiles = [ @@ -106,24 +113,29 @@ const componentCandidates = []; // Scan __next__ directories for upcoming components const nextComponentFiles = fg.sync( - ["src/components/**/__next__/index.{ts,tsx}", "src/components/**/__next__/*.component.{ts,tsx}"], - { - cwd: repoRoot, + [ + "src/components/**/__next__/index.{ts,tsx}", + "src/components/**/__next__/*.component.{ts,tsx}", + ], + { + cwd: repoRoot, absolute: true, - ignore: ["**/__internal__/**"] - } + ignore: ["**/__internal__/**"], + }, ); for (const filePath of nextComponentFiles) { const sourceFile = project.addSourceFileAtPathIfExists(filePath); if (!sourceFile) continue; - + const exported = sourceFile.getExportedDeclarations(); for (const [exportName, declarations] of exported.entries()) { if (exportName === "default" || exportName.endsWith("Props")) continue; if (!/^[A-Z]/.test(exportName)) continue; - - const relativePath = "./" + path.relative(path.join(repoRoot, "src"), filePath).replace(/\\/g, "/"); + + const relativePath = + "./" + + path.relative(path.join(repoRoot, "src"), filePath).replace(/\\/g, "/"); const rawModuleSpecifier = relativePath .replace(/\/index\.(ts|tsx)$/, "") .replace(/\.(ts|tsx)$/, ""); @@ -131,11 +143,11 @@ for (const filePath of nextComponentFiles) { /(\/__next__)\/.*\.component$/, "$1", ); - + // Keep original name for props lookup, use display name for output componentCandidates.push({ - name: exportName, // Original name for props lookup - displayName: `${exportName}Next`, // Display name for output files + name: exportName, // Original name for props lookup + displayName: `${exportName}Next`, // Display name for output files moduleSpecifier, }); } @@ -171,7 +183,8 @@ for (const exportDecl of indexFile.getExportDeclarations()) { } } -const uniqueComponentCandidates = dedupeComponentCandidates(componentCandidates); +const uniqueComponentCandidates = + dedupeComponentCandidates(componentCandidates); /** @type {ComponentData[]} */ const componentData = []; @@ -186,19 +199,21 @@ for (const candidate of uniqueComponentCandidates) { } const moduleDir = getModuleDir(modulePath); - const moduleFiles = fg.sync(["**/*.{ts,tsx}"], { - cwd: moduleDir, - absolute: true, - ignore: [ - "**/*.spec.*", - "**/*.test.*", - "**/*.stories.*", - "**/*.pw.*", - "**/*.mdx", - "**/__internal__/**", - "**/__next__/**", - ], - }).sort(); + const moduleFiles = fg + .sync(["**/*.{ts,tsx}"], { + cwd: moduleDir, + absolute: true, + ignore: [ + "**/*.spec.*", + "**/*.test.*", + "**/*.stories.*", + "**/*.pw.*", + "**/*.mdx", + "**/__internal__/**", + "**/__next__/**", + ], + }) + .sort(); for (const filePath of moduleFiles) { project.addSourceFileAtPathIfExists(filePath); @@ -243,17 +258,56 @@ for (const candidate of uniqueComponentCandidates) { } const storyDataByComponent = await extractStoryData(project, repoRoot); +const componentMetadata = await loadComponentMetadata(componentMetadataDir); +validateMetadataReferences( + componentMetadata, + componentData, + storyDataByComponent, +); + +if (metadataStatusMode) { + const activeComponents = componentData.filter( + ({ deprecated }) => !deprecated, + ); + const reviewed = activeComponents + .filter(({ name }) => componentMetadata.has(name)) + .map(({ name }) => name) + .sort(); + const pending = activeComponents + .filter(({ name }) => !componentMetadata.has(name)) + .map(({ name }) => name) + .sort(); + const deprecated = componentData + .filter( + ({ name, deprecated }) => deprecated && !componentMetadata.has(name), + ) + .map(({ name }) => name) + .sort(); + await new Promise((resolve) => + process.stdout.write( + `Active components with authored metadata: ${reviewed.length}/${activeComponents.length}\n${reviewed.join("\n")}\n\nPending active review: ${pending.length}/${activeComponents.length}\n${pending.join("\n")}\n\nDeprecated without authored metadata (not required): ${deprecated.length}\n${deprecated.join("\n")}\n`, + () => resolve(undefined), + ), + ); + process.exit(0); +} /** @type {Array<{path: string, content: string}>} */ const wouldWrite = []; /** @type {Map} */ const lineEndingPreferences = !checkMode - ? await collectLineEndingPreferences([componentsOutDir, referencesDir, skillsRoot]) + ? await collectLineEndingPreferences([ + componentsOutDir, + examplesOutDir, + referencesDir, + skillsRoot, + ]) : new Map(); if (!checkMode) { await fs.rm(componentsOutDir, { recursive: true, force: true }); + await fs.rm(examplesOutDir, { recursive: true, force: true }); await fs.rm(referencesDir, { recursive: true, force: true }); await fs.mkdir(componentsOutDir, { recursive: true }); await fs.mkdir(skillsRoot, { recursive: true }); @@ -261,7 +315,10 @@ if (!checkMode) { } const skillRootContent = renderSkillRootContent(); -wouldWrite.push({ path: path.join(skillsRoot, "SKILL.md"), content: skillRootContent }); +wouldWrite.push({ + path: path.join(skillsRoot, "SKILL.md"), + content: skillRootContent, +}); for (const relativePath of docsReferenceFiles) { const sourcePath = path.join(repoRoot, relativePath); @@ -269,9 +326,14 @@ for (const relativePath of docsReferenceFiles) { continue; } const fileName = path.basename(sourcePath); - const targetPath = path.join(referencesDir, fileName).replace(/\.mdx?$/, ".md"); + const targetPath = path + .join(referencesDir, fileName) + .replace(/\.mdx?$/, ".md"); const content = await fs.readFile(sourcePath, "utf8"); - wouldWrite.push({ path: targetPath, content: content.replace(/\r\n/g, "\n") }); + wouldWrite.push({ + path: targetPath, + content: content.replace(/\r\n/g, "\n"), + }); } const indexLines = ["# Carbon Component Catalog", "", "## Components", ""]; @@ -280,28 +342,68 @@ for (const component of componentData.sort((a, b) => a.name.localeCompare(b.name), )) { const stories = storyDataByComponent.get(component.name) ?? []; + const metadata = componentMetadata.get(component.name) ?? null; const fileName = `${toKebabCase(component.name)}.md`; const filePath = path.join(componentsOutDir, fileName); - const markdown = renderComponentMarkdown(component, stories); + const curatedExamples = metadata + ? (metadata.examples?.map((example) => { + const story = stories.find( + (candidate) => candidate.exportName === example.story, + ); + if (!story) { + throw new Error( + `Component metadata for ${component.name} references missing story ${example.story}`, + ); + } + const exampleFileName = `${toKebabCase(example.story)}.md`; + const examplePath = path.join( + examplesOutDir, + toKebabCase(component.name), + exampleFileName, + ); + wouldWrite.push({ + path: examplePath, + content: renderExampleMarkdown(component, story), + }); + return { + title: story.name, + description: example.description, + path: `../examples/${toKebabCase(component.name)}/${exampleFileName}`, + }; + }) ?? []) + : []; + const markdown = renderComponentMarkdown( + component, + stories, + metadata, + curatedExamples, + ); wouldWrite.push({ path: filePath, content: markdown }); const deprecatedLabel = component.deprecated ? " (deprecated)" : ""; + const summary = metadata ? ` — ${metadata.summary}` : ""; indexLines.push( - `- [${component.name}](components/${fileName})${deprecatedLabel}`, + `- [${component.name}](components/${fileName})${deprecatedLabel}${summary}`, ); } const indexContent = indexLines.join("\n"); -wouldWrite.push({ path: path.join(skillsRoot, "index.md"), content: indexContent }); +wouldWrite.push({ + path: path.join(skillsRoot, "index.md"), + content: indexContent, +}); if (checkMode) { const { hasDiff, diffSummary } = await checkWouldWrite(wouldWrite, { componentsOutDir, + examplesOutDir, referencesDir, }); if (hasDiff) { // eslint-disable-next-line no-console -- CI output - console.error("Skills build check failed: files on disk differ from expected output:\n"); + console.error( + "Skills build check failed: files on disk differ from expected output:\n", + ); // eslint-disable-next-line no-console -- CI output console.error(diffSummary); process.exit(1); @@ -352,14 +454,18 @@ async function collectLineEndingPreferences(directories) { continue; } - const files = await fs.readdir(dir, { recursive: true, withFileTypes: true }); + const files = await fs.readdir(dir, { + recursive: true, + withFileTypes: true, + }); for (const file of files) { if (!file.isFile()) { continue; } - const fullPath = path.join(file.parentPath, file.name); + const parentPath = file.parentPath ?? file.path ?? dir; + const fullPath = path.join(parentPath, file.name); try { const content = await fs.readFile(fullPath, "utf8"); @@ -382,7 +488,11 @@ async function collectLineEndingPreferences(directories) { * @param {Map} preferences * @returns {Promise} */ -async function applyExistingLineEndingPreference(filePath, content, preferences) { +async function applyExistingLineEndingPreference( + filePath, + content, + preferences, +) { const key = normalizePathKey(filePath); const preference = preferences.get(key); @@ -528,19 +638,21 @@ function resolvePropsDefinition( if (!resolved) { continue; } - const targetFiles = fg.sync(["**/*.{ts,tsx}"], { - cwd: getModuleDir(resolved), - absolute: true, - ignore: [ - "**/*.spec.*", - "**/*.test.*", - "**/*.stories.*", - "**/*.pw.*", - "**/*.mdx", - "**/__internal__/**", - "**/__next__/**", - ], - }).sort(); + const targetFiles = fg + .sync(["**/*.{ts,tsx}"], { + cwd: getModuleDir(resolved), + absolute: true, + ignore: [ + "**/*.spec.*", + "**/*.test.*", + "**/*.stories.*", + "**/*.pw.*", + "**/*.mdx", + "**/__internal__/**", + "**/__next__/**", + ], + }) + .sort(); const resolvedDefinition = findTypeDefinitionInFiles( projectInstance, @@ -590,45 +702,85 @@ function extractPropsFromDefinition(propsDefinition, defaultsMap) { .getProperties() .filter((symbol) => !shouldExcludePropSymbol(symbol)) .map((symbol) => { - const declaration = symbol - .getDeclarations() - .find( - (decl) => - decl.isKind(SyntaxKind.PropertySignature) || - decl.isKind(SyntaxKind.PropertyDeclaration), + const declaration = symbol + .getDeclarations() + .find( + (decl) => + decl.isKind(SyntaxKind.PropertySignature) || + decl.isKind(SyntaxKind.PropertyDeclaration), + ); + const propType = declaration + ? declaration.getType() + : symbol.getTypeAtLocation(propsDefinition.node); + const literals = getLiteralUnionValues(propType); + const jsDocs = declaration?.getJsDocs?.() ?? []; + const description = jsDocs + .map((doc) => doc.getDescription().trim()) + .filter(Boolean) + .join(" "); + const deprecationInfo = getDeprecationFromJsDocs(jsDocs); + const required = declaration?.isKind(SyntaxKind.PropertySignature) + ? !declaration.hasQuestionToken() + : true; + + const resolvedText = propType.getText( + declaration ?? propsDefinition.node, ); - const propType = declaration - ? declaration.getType() - : symbol.getTypeAtLocation(propsDefinition.node); - const literals = getLiteralUnionValues(propType); - const jsDocs = declaration?.getJsDocs?.() ?? []; - const description = jsDocs - .map((doc) => doc.getDescription().trim()) - .filter(Boolean) - .join(" "); - const deprecationInfo = getDeprecationFromJsDocs(jsDocs); - const required = declaration?.isKind(SyntaxKind.PropertySignature) - ? !declaration.hasQuestionToken() - : true; - - const resolvedText = propType.getText(declaration ?? propsDefinition.node); - const typeNode = declaration?.getTypeNode?.(); - const typeText = ( - resolvedText.includes("import(") && typeNode - ? typeNode.getText() - : resolvedText - ).replace(/\s+/g, " ").trim(); - - return { - name: symbol.getName(), - type: typeText, - required, - literals, - description: description || null, - defaultValue: defaultsMap.get(symbol.getName()) ?? null, - deprecated: deprecationInfo.deprecated, - deprecationReason: deprecationInfo.reason, - }; + const typeNode = declaration?.getTypeNode?.(); + const typeText = ( + resolvedText.includes("import(") && typeNode + ? typeNode.getText() + : resolvedText + ) + .replace(/\s+/g, " ") + .trim(); + const deprecatedValues = extractDeprecatedLiteralValues( + deprecationInfo.reason, + literals, + typeText, + ); + + return { + name: symbol.getName(), + type: typeText, + required, + literals, + description: description || null, + defaultValue: defaultsMap.get(symbol.getName()) ?? null, + deprecated: deprecationInfo.deprecated && !deprecatedValues.length, + deprecatedValues, + deprecationReason: deprecationInfo.reason, + }; + }); +} + +/** + * Some existing JSDoc uses @deprecated on a prop to describe one deprecated + * literal rather than the whole prop. Preserve that distinction in the skill. + * + * @param {string | null} reason + * @param {Array | null} literals + * @param {string} typeText + * @returns {Array} + */ +function extractDeprecatedLiteralValues(reason, literals, typeText) { + if (!reason) return []; + const deprecatedClause = reason.match( + /^(.*?)(?:\bis\s+deprecated\b|\bare\s+deprecated\b)/i, + )?.[1]; + if (!deprecatedClause) return []; + const mentionedValues = [...deprecatedClause.matchAll(/`([^`]+)`/g)].map( + (match) => match[1], + ); + const candidates = literals?.length ? literals : mentionedValues; + return candidates.filter((literal) => { + if (!deprecatedClause.includes(`\`${String(literal)}\``)) return false; + const value = String(literal); + return ( + typeText.includes(`"${value}"`) || + typeText.includes(`'${value}'`) || + typeText.split(/\s*\|\s*/).includes(value) + ); }); } @@ -881,14 +1033,21 @@ function extractFromParameters(parameters, defaults) { * @returns {Promise>} */ async function extractStoryData(projectInstance, rootDir) { - const storyFiles = fg.sync( - ["src/**/*.stories.@(js|jsx|ts|tsx)", "docs/**/*.stories.@(js|jsx|ts|tsx)"], - { cwd: rootDir, absolute: true }, - ).sort(); - const mdxFiles = fg.sync(["src/**/*.mdx", "docs/**/*.mdx"], { - cwd: rootDir, - absolute: true, - }).sort(); + const storyFiles = fg + .sync( + [ + "src/**/*.stories.@(js|jsx|ts|tsx)", + "docs/**/*.stories.@(js|jsx|ts|tsx)", + ], + { cwd: rootDir, absolute: true }, + ) + .sort(); + const mdxFiles = fg + .sync(["src/**/*.mdx", "docs/**/*.mdx"], { + cwd: rootDir, + absolute: true, + }) + .sort(); /** @type {Map} */ const storyMap = new Map(); @@ -935,9 +1094,11 @@ async function extractStoryData(projectInstance, rootDir) { const existing = storyMap.get(componentName) ?? []; for (const example of examples) { existing.push({ + exportName: example.name, name: example.name, argsText: example.code, renderText: null, + importsText: null, source: path.relative(rootDir, filePath), kind: "mdx", }); @@ -1030,10 +1191,13 @@ function extractStoryExports(sourceFile) { for (const declaration of declarations) { if (declaration.isKind(SyntaxKind.FunctionDeclaration)) { + const renderText = declaration.getText(); stories.push({ + exportName, name: storyNameOverrides.get(exportName) ?? exportName, argsText: storyArgsOverrides.get(exportName) ?? null, - renderText: declaration.getText(), + renderText, + importsText: extractRelevantImports(sourceFile, [renderText]), kind: "csf", }); continue; @@ -1060,9 +1224,14 @@ function extractStoryExports(sourceFile) { : null; stories.push({ + exportName, name: storyNameOverrides.get(exportName) ?? exportName, argsText, renderText, + importsText: extractRelevantImports(sourceFile, [ + argsText, + renderText, + ]), kind: "csf", }); continue; @@ -1073,10 +1242,13 @@ function extractStoryExports(sourceFile) { initializer.isKind(SyntaxKind.FunctionExpression) ) { // CSF function style: export const Story = () => + const renderText = initializer.getText(); stories.push({ + exportName, name: storyNameOverrides.get(exportName) ?? exportName, argsText: storyArgsOverrides.get(exportName) ?? null, - renderText: initializer.getText(), + renderText, + importsText: extractRelevantImports(sourceFile, [renderText]), kind: "csf", }); } @@ -1086,6 +1258,59 @@ function extractStoryExports(sourceFile) { return stories; } +/** + * Keep only imports referenced by a generated example. This removes Storybook + * types, actions and unrelated story setup from the agent-facing snippet. + * + * @param {import("ts-morph").SourceFile} sourceFile + * @param {Array} codeParts + * @returns {string | null} + */ +function extractRelevantImports(sourceFile, codeParts) { + const code = codeParts.filter(Boolean).join("\n"); + const uses = (/** @type {string} */ identifier) => + new RegExp(`\\b${identifier.replace(/[$]/g, "\\$")}\\b`).test(code); + const imports = []; + + for (const declaration of sourceFile.getImportDeclarations()) { + const defaultImport = declaration.getDefaultImport()?.getText() ?? null; + const namespaceImport = declaration.getNamespaceImport()?.getText() ?? null; + const namedImports = declaration.getNamedImports().filter((namedImport) => { + const localName = + namedImport.getAliasNode()?.getText() ?? namedImport.getName(); + return uses(localName); + }); + + const selectedDefault = + defaultImport && uses(defaultImport) ? defaultImport : null; + const selectedNamespace = + namespaceImport && uses(namespaceImport) ? namespaceImport : null; + if (!selectedDefault && !selectedNamespace && !namedImports.length) + continue; + + const specifiers = []; + if (selectedDefault) specifiers.push(selectedDefault); + if (selectedNamespace) specifiers.push(`* as ${selectedNamespace}`); + if (namedImports.length) { + specifiers.push( + `{ ${namedImports + .map((namedImport) => { + const alias = namedImport.getAliasNode()?.getText(); + return alias + ? `${namedImport.getName()} as ${alias}` + : namedImport.getName(); + }) + .join(", ")} }`, + ); + } + imports.push( + `import ${specifiers.join(", ")} from ${JSON.stringify(declaration.getModuleSpecifierValue())};`, + ); + } + + return imports.length ? imports.join("\n") : null; +} + /** * Infers the component name associated with a story by checking the story's metadata for a title or component reference, and if those are not available, by analyzing the file path to find the nearest directory that likely corresponds to the component name. The function uses PascalCase formatting for the inferred component name. * @param {StoryMeta} meta @@ -1250,19 +1475,35 @@ function extractMdxExamples(content) { * @returns {string} */ function renderSkillRootContent() { + const docsByUse = new Map([ + ["installation.md", "installation and configuration"], + ["usage.md", "package setup and basic use"], + ["recommended-practices.md", "project-wide conventions"], + ["usage-with-routing.md", "routing integration"], + ["extending-styles-using-styled-components.md", "style extension"], + ["colors.md", "colours and tokens"], + ["i18n.md", "translation"], + ["deprecation-migration.md", "deprecation migration"], + ]); const docsList = docsReferenceTargets - .map((fileName) => `- \`${fileName}\``) + .map( + (fileName) => + `- \`${fileName}\`: ${docsByUse.get(path.basename(fileName)) ?? "related guidance"}.`, + ) .join("\n"); - return `---\nname: carbon-react\ndescription: Carbon component catalog with typed props, Storybook usage examples, and curated docs references. Use when answering questions about Carbon components, props, and usage guidance.\n---\n\n# Carbon Component Catalog\n\nUse \`index.md\` to find the component file.\nUse \`components/*.md\` to read props and examples.\nUse these docs references:\n${docsList}\nDeprecated components are marked in \`index.md\` and in each component file.\n`; + return `---\nname: carbon-react\ndescription: Use for Carbon React selection and implementation: imports, props, defaults, deprecations, examples, and guidance.\n---\n\n# Carbon Component Catalog\n\nUse \`index.md\` to select a component, then open its file and only the relevant linked examples.\n\nComponent files combine authored selection guidance with source-derived API details. Curated components link to selected examples and omit playgrounds; others retain generated Storybook examples.\n\nLoad references only when needed:\n\n${docsList}\n\nBefore finishing, confirm that imports and props match the component file and that deprecated APIs are either avoided or handled using the migration guidance.\n`; } /** * Checks that files on disk match the expected content. Returns diffs for CI. * @param {Array<{path: string, content: string}>} wouldWrite - * @param {{componentsOutDir: string, referencesDir: string}} outputDirs + * @param {{componentsOutDir: string, examplesOutDir: string, referencesDir: string}} outputDirs * @returns {Promise<{hasDiff: boolean, diffSummary: string}>} */ -async function checkWouldWrite(wouldWrite, { componentsOutDir, referencesDir }) { +async function checkWouldWrite( + wouldWrite, + { componentsOutDir, examplesOutDir, referencesDir }, +) { /** @type {string[]} */ const diffs = []; const expectedPaths = new Set(wouldWrite.map((w) => w.path)); @@ -1272,7 +1513,12 @@ async function checkWouldWrite(wouldWrite, { componentsOutDir, referencesDir }) try { existing = await fs.readFile(filePath, "utf8"); } catch (err) { - if (err && typeof err === 'object' && 'code' in err && err?.code === "ENOENT") { + if ( + err && + typeof err === "object" && + "code" in err && + err?.code === "ENOENT" + ) { diffs.push(` Missing: ${path.relative(repoRoot, filePath)}`); continue; } @@ -1283,16 +1529,20 @@ async function checkWouldWrite(wouldWrite, { componentsOutDir, referencesDir }) } } - for (const dir of [componentsOutDir, referencesDir]) { + for (const dir of [componentsOutDir, examplesOutDir, referencesDir]) { if (!existsSync(dir)) { continue; } - const entries = await fs.readdir(dir, { withFileTypes: true }); + const entries = await fs.readdir(dir, { + recursive: true, + withFileTypes: true, + }); for (const entry of entries) { if (!entry.isFile()) { continue; } - const fullPath = path.join(dir, entry.name); + const parentPath = entry.parentPath ?? entry.path ?? dir; + const fullPath = path.join(parentPath, entry.name); if (!expectedPaths.has(fullPath)) { diffs.push(` Extra: ${path.relative(repoRoot, fullPath)}`); } @@ -1462,12 +1712,19 @@ function getDeprecationFromJsDocs(jsDocs) { } /** - * Render a component markdown file with props and examples. + * Render a component markdown file with authored guidance and AST-derived API. * @param {ComponentData} component * @param {StoryEntry[]} stories + * @param {import("./component-metadata.mjs").ComponentMetadata | null} metadata + * @param {Array<{title: string, description: string, path: string}>} curatedExamples * @returns {string} */ -function renderComponentMarkdown(component, stories) { +function renderComponentMarkdown( + component, + stories, + metadata, + curatedExamples, +) { const frontmatter = [ "---", `name: carbon-component-${toKebabCase(component.name)}`, @@ -1478,6 +1735,22 @@ function renderComponentMarkdown(component, stories) { const lines = [frontmatter, `# ${component.name}`, ""]; + if (metadata) { + lines.push(metadata.summary, ""); + renderTextList(lines, "When to use", metadata.useWhen ?? []); + renderTextList(lines, "When not to use", metadata.avoidWhen ?? []); + + if (metadata.alternatives?.length) { + lines.push("## Choose instead", ""); + for (const alternative of metadata.alternatives) { + lines.push(`- **${alternative.component}:** ${alternative.when}`); + } + lines.push(""); + } + + renderTextList(lines, "Pitfalls", metadata.pitfalls ?? []); + } + const importPath = `${packageName}/${buildOutputFolder}/${component.moduleSpecifier.replace("./", "")}`; const importStatement = component.hasDefaultExport ? `import ${component.importName} from "${importPath}";` @@ -1506,19 +1779,26 @@ function renderComponentMarkdown(component, stories) { lines.push("No props metadata found."); } else { const sortedProps = [...component.props].sort((a, b) => { - if (a.deprecated !== b.deprecated) return a.deprecated ? 1 : -1; + const aDeprecated = a.deprecated || a.deprecatedValues.length > 0; + const bDeprecated = b.deprecated || b.deprecatedValues.length > 0; + if (aDeprecated !== bDeprecated) return aDeprecated ? 1 : -1; if (a.required !== b.required) return a.required ? -1 : 1; const aData = a.name.startsWith("data-"); const bData = b.name.startsWith("data-"); const aAria = a.name.startsWith("aria-"); const bAria = b.name.startsWith("aria-"); - const groupOrder = (/** @type {boolean} */ data, /** @type {boolean} */ aria) => (data ? 1 : aria ? 2 : 0); + const groupOrder = ( + /** @type {boolean} */ data, + /** @type {boolean} */ aria, + ) => (data ? 1 : aria ? 2 : 0); const ga = groupOrder(aData, aAria); const gb = groupOrder(bData, bAria); if (ga !== gb) return ga - gb; return a.name.localeCompare(b.name); }); - const hasDeprecatedProps = sortedProps.some((prop) => prop.deprecated); + const hasDeprecatedProps = sortedProps.some( + (prop) => prop.deprecated || prop.deprecatedValues.length > 0, + ); if (hasDeprecatedProps) { lines.push( "| Name | Type | Required | Literals | Deprecated | Deprecation reason | Description | Default |", @@ -1535,8 +1815,14 @@ function renderComponentMarkdown(component, stories) { const description = prop.description?.replace(/\s+/g, " ") ?? ""; const defaultValue = (prop.defaultValue ?? "").replace(/\r/g, ""); if (hasDeprecatedProps) { - const deprecated = prop.deprecated ? "Yes" : ""; - const deprecationReason = (prop.deprecationReason ?? "").replace(/\s+/g, " ").trim(); + const deprecated = prop.deprecated + ? "Yes" + : prop.deprecatedValues.length + ? `Values: ${prop.deprecatedValues.map((value) => JSON.stringify(value)).join(", ")}` + : ""; + const deprecationReason = (prop.deprecationReason ?? "") + .replace(/\s+/g, " ") + .trim(); lines.push( `| ${prop.name} | ${escapePipes(prop.type)} | ${prop.required ? "Yes" : "No"} | ${escapePipes(literals)} | ${deprecated} | ${escapePipes(deprecationReason)} | ${escapePipes(description)} | ${escapePipes(defaultValue)} |`, ); @@ -1550,17 +1836,45 @@ function renderComponentMarkdown(component, stories) { lines.push(""); lines.push("## Examples"); - if (!stories.length) { + if (metadata) { + if (!curatedExamples.length) { + lines.push("No examples have been curated for agent use."); + } else { + lines.push( + "Load only the example needed for the current task; playground stories are intentionally omitted.", + "", + ); + for (const example of curatedExamples) { + lines.push( + `- [${example.title}](${example.path}) — ${example.description}`, + ); + } + } + } else if (!stories.length) { lines.push("No Storybook examples found."); } else { for (const story of stories) { lines.push(`### ${story.name}`); lines.push(""); if (story.argsText) { - lines.push("**Args**", "", "```tsx", story.argsText.replace(/\r\n/g, "\n"), "```", ""); + lines.push( + "**Args**", + "", + "```tsx", + story.argsText.replace(/\r\n/g, "\n"), + "```", + "", + ); } if (story.renderText) { - lines.push("**Render**", "", "```tsx", story.renderText.replace(/\r\n/g, "\n"), "```", ""); + lines.push( + "**Render**", + "", + "```tsx", + story.renderText.replace(/\r\n/g, "\n"), + "```", + "", + ); } lines.push(""); } @@ -1569,6 +1883,239 @@ function renderComponentMarkdown(component, stories) { return lines.join("\n"); } +/** + * @param {string[]} lines + * @param {string} heading + * @param {string[]} items + */ +function renderTextList(lines, heading, items) { + if (!items.length) return; + lines.push(`## ${heading}`, ""); + for (const item of items) lines.push(`- ${item}`); + lines.push(""); +} + +/** + * Render one deliberately selected story independently from the component API + * and the rest of the story file. + * + * @param {ComponentData} component + * @param {StoryEntry} story + * @returns {string} + */ +function renderExampleMarkdown(component, story) { + if (!story.renderText) { + throw new Error( + `Curated story ${component.name}.${story.exportName} has no render function`, + ); + } + + const imports = rewriteExampleImports(story.importsText, story.source); + const exampleName = `${component.name}${story.exportName}Example`; + let render = normalizeCodeIndentation(story.renderText.trim()); + + if (story.argsText && /^(?:async\s+)?\(\s*args\s*\)\s*=>\s*\{/.test(render)) { + render = render.replace( + /^(async\s+)?\(\s*args\s*\)\s*=>\s*\{/, + (_, asyncKeyword = "") => + `${asyncKeyword}() => {\n const args = ${story.argsText};`, + ); + } else if (story.argsText && /\bargs\b/.test(render)) { + throw new Error( + `Curated story ${component.name}.${story.exportName} uses args in a form the skill generator cannot safely resolve`, + ); + } + + const code = [imports, `export const ${exampleName} = ${render};`] + .filter(Boolean) + .join("\n\n"); + + return `# ${component.name}: ${story.name}\n\n\`\`\`tsx\n${code}\n\`\`\`\n`; +} + +/** + * Remove source-file indentation while preserving the relative indentation of + * a function body copied from a variable initializer. + * + * @param {string} code + * @returns {string} + */ +function normalizeCodeIndentation(code) { + const lines = code.split("\n"); + const indents = lines + .slice(1, -1) + .filter((line) => line.trim()) + .map((line) => line.match(/^\s*/)?.[0].length ?? 0) + .filter((indent) => indent > 0); + const indentation = indents.length + ? Math.max(0, Math.min(...indents) - 2) + : 0; + if (!indentation) return code; + const prefix = " ".repeat(indentation); + return [ + lines[0], + ...lines + .slice(1) + .map((line) => + line.startsWith(prefix) ? line.slice(indentation) : line, + ), + ].join("\n"); +} + +/** + * Convert story-relative component imports into consumer-facing Carbon imports. + * Build helpers outside src/components retain their source import so a reviewer + * can decide whether that story is suitable as a public example. + * + * @param {string | null} importsText + * @param {string | undefined} storySource + * @returns {string} + */ +function rewriteExampleImports(importsText, storySource) { + if (!importsText || !storySource) return importsText ?? ""; + const storyPath = path.join(repoRoot, storySource); + + return importsText.replace( + /(from\s+)(["'])(\.[^"']*)\2/g, + (match, prefix, quote, moduleSpecifier) => { + const modulePath = resolveModulePathFrom(storyPath, moduleSpecifier); + if (!modulePath) return match; + const relative = path + .relative(path.join(repoRoot, "src", "components"), modulePath) + .replace(/\\/g, "/"); + if (relative.startsWith("../")) return match; + + const publicModule = relative + .replace(/\/index\.(?:ts|tsx)$/, "") + .replace(/\.(?:ts|tsx)$/, "") + .replace(/\/[^/]+\.component$/, ""); + return `${prefix}${quote}${packageName}/${buildOutputFolder}/components/${publicModule}${quote}`; + }, + ); +} + +/** + * Validate authored links against AST-discovered components and Storybook + * exports. Invalid metadata must fail generation rather than drift silently. + * + * @param {Map} metadataByComponent + * @param {ComponentData[]} components + * @param {Map} storiesByComponent + */ +function validateMetadataReferences( + metadataByComponent, + components, + storiesByComponent, +) { + const componentNames = new Set(components.map(({ name }) => name)); + const componentsByName = new Map( + components.map((component) => [component.name, component]), + ); + const deprecatedImports = components + .filter(({ deprecated }) => deprecated) + .map(({ name, moduleSpecifier }) => ({ + name, + importPath: `${packageName}/${buildOutputFolder}/${moduleSpecifier.replace("./", "")}`, + })); + + for (const metadata of metadataByComponent.values()) { + if (!componentNames.has(metadata.component)) { + throw new Error( + `Component metadata references unknown public component ${metadata.component}`, + ); + } + for (const alternative of metadata.alternatives ?? []) { + if (alternative.component === metadata.component) { + throw new Error( + `Component metadata for ${metadata.component} recommends itself as an alternative`, + ); + } + const alternativeComponent = componentsByName.get(alternative.component); + if (!alternativeComponent) { + throw new Error( + `Component metadata for ${metadata.component} references unknown alternative ${alternative.component}`, + ); + } + if (alternativeComponent.deprecated) { + throw new Error( + `Component metadata for ${metadata.component} recommends deprecated alternative ${alternative.component}`, + ); + } + } + + const stories = storiesByComponent.get(metadata.component) ?? []; + const component = componentsByName.get(metadata.component); + for (const example of metadata.examples ?? []) { + if (/playground/i.test(example.story)) { + throw new Error( + `Component metadata for ${metadata.component} must not curate playground story ${example.story}`, + ); + } + const story = stories.find( + (candidate) => candidate.exportName === example.story, + ); + if (!story) { + throw new Error( + `Component metadata for ${metadata.component} references missing story export ${example.story}`, + ); + } + if (!story.renderText) { + throw new Error( + `Curated story ${metadata.component}.${example.story} has no render function`, + ); + } + const imports = rewriteExampleImports(story.importsText, story.source); + if (/from\s+["']\./.test(imports)) { + throw new Error( + `Curated story ${metadata.component}.${example.story} contains a relative import that cannot be used by a consumer`, + ); + } + const deprecatedDependency = deprecatedImports.find(({ importPath }) => + new RegExp( + `from\\s+["']${importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`, + ).test(imports), + ); + if (deprecatedDependency) { + throw new Error( + `Curated story ${metadata.component}.${example.story} imports deprecated component ${deprecatedDependency.name}`, + ); + } + const storyCode = [story.argsText, story.renderText] + .filter(Boolean) + .join("\n"); + const deprecatedProp = component?.props.find((prop) => + storyUsesDeprecatedProp(storyCode, prop), + ); + if (deprecatedProp) { + throw new Error( + `Curated story ${metadata.component}.${example.story} uses deprecated prop ${deprecatedProp.name}`, + ); + } + } + } +} + +/** + * @param {string} code + * @param {PropInfo} prop + */ +function storyUsesDeprecatedProp(code, prop) { + const propName = prop.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if (prop.deprecated) { + return new RegExp(`\\b${propName}\\s*(?:=|:)`).test(code); + } + return prop.deprecatedValues.some((value) => { + const escapedValue = String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const literal = + typeof value === "string" + ? `["']${escapedValue}["']` + : `\\b${escapedValue}\\b`; + return new RegExp( + `\\b${propName}\\s*(?:=|:)\\s*(?:\\{\\s*)?${literal}(?:\\s*\\})?`, + ).test(code); + }); +} + /** * Escape pipes for markdown table cells. * @param {string} value @@ -1637,7 +2184,9 @@ function dedupeComponentCandidates(candidates) { continue; } - if (scoreComponentCandidate(candidate) > scoreComponentCandidate(existing)) { + if ( + scoreComponentCandidate(candidate) > scoreComponentCandidate(existing) + ) { byOutputName.set(outputName, candidate); } } @@ -1661,4 +2210,4 @@ function scoreComponentCandidate(candidate) { score += 1; } return score; -} \ No newline at end of file +} diff --git a/scripts/skills/component-metadata.mjs b/scripts/skills/component-metadata.mjs new file mode 100644 index 0000000000..71ce73b719 --- /dev/null +++ b/scripts/skills/component-metadata.mjs @@ -0,0 +1,220 @@ +// @ts-check +import fs from "node:fs/promises"; +import path from "node:path"; +import fg from "fast-glob"; + +/** + * Human-authored guidance that cannot be derived reliably from component code. + * + * @typedef {Object} ComponentMetadata + * @property {string} component + * @property {string} summary + * @property {string[]} [useWhen] + * @property {string[]} [avoidWhen] + * @property {Array<{component: string, when: string}>} [alternatives] + * @property {string[]} [pitfalls] + * @property {Array<{story: string, description: string}>} [examples] + */ + +/** + * Load and validate build-time component guidance. + * + * These files deliberately live outside `src` so they cannot become runtime + * package modules. The skill generator is their only consumer. + * + * @param {string} metadataDir + * @returns {Promise>} + */ +export async function loadComponentMetadata(metadataDir) { + const metadataByComponent = new Map(); + const files = fg.sync("*.json", { cwd: metadataDir, absolute: true }).sort(); + + for (const filePath of files) { + let value; + try { + value = JSON.parse(await fs.readFile(filePath, "utf8")); + } catch (error) { + throw new Error( + `${path.relative(process.cwd(), filePath)} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const metadata = validateComponentMetadata(value, filePath); + const expectedFileName = `${toKebabCase(metadata.component)}.json`; + if (path.basename(filePath) !== expectedFileName) { + throw new Error( + `${path.relative(process.cwd(), filePath)} must be named ${expectedFileName}`, + ); + } + if (metadataByComponent.has(metadata.component)) { + throw new Error( + `Duplicate component metadata for ${metadata.component}: ${path.relative(process.cwd(), filePath)}`, + ); + } + metadataByComponent.set(metadata.component, metadata); + } + + return metadataByComponent; +} + +/** + * @param {unknown} value + * @param {string} filePath + * @returns {ComponentMetadata} + */ +export function validateComponentMetadata( + value, + filePath = "component metadata", +) { + if (!isRecord(value)) fail(filePath, "must contain a JSON object"); + + const allowedKeys = new Set([ + "component", + "summary", + "useWhen", + "avoidWhen", + "alternatives", + "pitfalls", + "examples", + ]); + for (const key of Object.keys(value)) { + if (!allowedKeys.has(key)) + fail(filePath, `contains unknown field \"${key}\"`); + } + + requireText(value.component, filePath, "component"); + requireText(value.summary, filePath, "summary"); + requireTextArray(value.useWhen, filePath, "useWhen"); + requireTextArray(value.avoidWhen, filePath, "avoidWhen"); + requireTextArray(value.pitfalls, filePath, "pitfalls"); + + if (value.alternatives !== undefined) { + if (!Array.isArray(value.alternatives) || value.alternatives.length === 0) { + fail( + filePath, + 'field "alternatives" must be a non-empty array when provided', + ); + } + const components = new Set(); + for (const [index, alternative] of value.alternatives.entries()) { + if (!isRecord(alternative)) { + fail(filePath, `alternatives[${index}] must be an object`); + } + rejectUnknownKeys( + alternative, + new Set(["component", "when"]), + filePath, + `alternatives[${index}]`, + ); + requireText( + alternative.component, + filePath, + `alternatives[${index}].component`, + ); + requireText(alternative.when, filePath, `alternatives[${index}].when`); + if (components.has(alternative.component)) { + fail( + filePath, + `references alternative \"${alternative.component}\" more than once`, + ); + } + components.add(alternative.component); + } + } + + if (value.examples !== undefined) { + if (!Array.isArray(value.examples) || value.examples.length === 0) { + fail( + filePath, + 'field "examples" must be a non-empty array when provided', + ); + } + const stories = new Set(); + for (const [index, example] of value.examples.entries()) { + if (!isRecord(example)) { + fail(filePath, `examples[${index}] must be an object`); + } + rejectUnknownKeys( + example, + new Set(["story", "description"]), + filePath, + `examples[${index}]`, + ); + requireText(example.story, filePath, `examples[${index}].story`); + requireText( + example.description, + filePath, + `examples[${index}].description`, + ); + if (stories.has(example.story)) { + fail(filePath, `selects story \"${example.story}\" more than once`); + } + stories.add(example.story); + } + } + + return /** @type {ComponentMetadata} */ (value); +} + +/** @param {unknown} value */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * @param {unknown} value + * @param {string} filePath + * @param {string} field + */ +function requireText(value, filePath, field) { + if (typeof value !== "string" || value.trim() === "") { + fail(filePath, `field \"${field}\" must be a non-empty string`); + } +} + +/** + * @param {unknown} value + * @param {string} filePath + * @param {string} field + * @param {boolean} [required] + */ +function requireTextArray(value, filePath, field, required = false) { + if (value === undefined && !required) return; + if (!Array.isArray(value) || value.length === 0) { + fail( + filePath, + `field \"${field}\" must be a non-empty array when provided`, + ); + } + for (const [index, item] of value.entries()) { + requireText(item, filePath, `${field}[${index}]`); + } +} + +/** + * @param {Record} value + * @param {Set} allowedKeys + * @param {string} filePath + * @param {string} field + */ +function rejectUnknownKeys(value, allowedKeys, filePath, field) { + for (const key of Object.keys(value)) { + if (!allowedKeys.has(key)) { + fail(filePath, `${field} contains unknown field \"${key}\"`); + } + } +} + +/** @param {string} value */ +function toKebabCase(value) { + return value + .replace(/([a-z])([A-Z])/g, "$1-$2") + .replace(/\s+/g, "-") + .replace(/_/g, "-") + .toLowerCase(); +} + +/** @param {string} filePath @param {string} message */ +function fail(filePath, message) { + throw new Error(`${path.relative(process.cwd(), filePath)} ${message}`); +} diff --git a/scripts/skills/component-metadata.test.mjs b/scripts/skills/component-metadata.test.mjs new file mode 100644 index 0000000000..ece5b38984 --- /dev/null +++ b/scripts/skills/component-metadata.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import fg from "fast-glob"; +import ts from "typescript"; + +import { validateComponentMetadata } from "./component-metadata.mjs"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); + +test("accepts concise authored component guidance", () => { + const metadata = validateComponentMetadata({ + component: "Pill", + summary: "Compact visual indicator.", + useWhen: ["Showing a short status."], + avoidWhen: ["The element performs an action."], + alternatives: [{ component: "Badge", when: "Showing a count." }], + pitfalls: ["Label removable pills."], + examples: [{ story: "Wrapped", description: "Allow long labels to wrap." }], + }); + + assert.equal(metadata.component, "Pill"); +}); + +test("does not require guidance that would only repeat the summary", () => { + const metadata = validateComponentMetadata({ + component: "Pill", + summary: "Compact visual indicator.", + }); + + assert.equal(metadata.component, "Pill"); + assert.equal(metadata.useWhen, undefined); +}); + +test("rejects duplicate curated stories", () => { + assert.throws( + () => + validateComponentMetadata({ + component: "Pill", + summary: "Compact visual indicator.", + useWhen: ["Showing a short status."], + examples: [ + { story: "Wrapped", description: "First." }, + { story: "Wrapped", description: "Second." }, + ], + }), + /selects story "Wrapped" more than once/, + ); +}); + +test("rejects unknown fields so guidance cannot be silently dropped", () => { + assert.throws( + () => + validateComponentMetadata({ + component: "Pill", + summary: "Compact visual indicator.", + useWhen: ["Showing a short status."], + usage: "This field is not supported.", + }), + /unknown field "usage"/, + ); +}); + +test("rejects unknown nested fields so guidance cannot be silently dropped", () => { + assert.throws( + () => + validateComponentMetadata({ + component: "Pill", + summary: "Compact visual indicator.", + examples: [ + { + story: "Wrapped", + description: "Allow long labels to wrap.", + source: "pill.stories.tsx", + }, + ], + }), + /examples\[0\] contains unknown field "source"/, + ); +}); + +test("rejects empty optional sections", () => { + assert.throws( + () => + validateComponentMetadata({ + component: "Pill", + summary: "Compact visual indicator.", + pitfalls: [], + }), + /field "pitfalls" must be a non-empty array when provided/, + ); +}); + +test("keeps authored metadata and generated skills out of the npm package", () => { + const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"), + ); + + assert.deepEqual([...packageJson.files].sort(), ["esm", "lib"]); + assert.equal( + path + .relative(repoRoot, path.join(repoRoot, "docs", "component-metadata")) + .split(path.sep)[0], + "docs", + ); +}); + +test("generated curated examples contain valid TSX", () => { + const exampleFiles = fg.sync("skills/carbon-react/examples/**/*.md", { + cwd: repoRoot, + absolute: true, + }); + assert.ok(exampleFiles.length, "expected at least one curated example"); + + for (const filePath of exampleFiles) { + const markdown = fs.readFileSync(filePath, "utf8"); + assert.doesNotMatch( + markdown, + /Source story:/, + `${path.relative(repoRoot, filePath)} must not reference repository-only story source`, + ); + const snippets = [...markdown.matchAll(/```tsx\n([\s\S]*?)```/g)]; + assert.ok( + snippets.length, + `${path.relative(repoRoot, filePath)} must contain a TSX snippet`, + ); + + for (const [, code] of snippets) { + const sourceFile = ts.createSourceFile( + filePath.replace(/\.md$/, ".tsx"), + code, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + assert.deepEqual( + sourceFile.parseDiagnostics.map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, " "), + ), + [], + `${path.relative(repoRoot, filePath)} contains invalid TSX`, + ); + } + } +}); diff --git a/skills/carbon-react/SKILL.md b/skills/carbon-react/SKILL.md index 71c6736971..859f392cc0 100644 --- a/skills/carbon-react/SKILL.md +++ b/skills/carbon-react/SKILL.md @@ -1,19 +1,23 @@ --- name: carbon-react -description: Carbon component catalog with typed props, Storybook usage examples, and curated docs references. Use when answering questions about Carbon components, props, and usage guidance. +description: Use for Carbon React selection and implementation: imports, props, defaults, deprecations, examples, and guidance. --- # Carbon Component Catalog -Use `index.md` to find the component file. -Use `components/*.md` to read props and examples. -Use these docs references: -- `references/docs/usage.md` -- `references/docs/installation.md` -- `references/docs/recommended-practices.md` -- `references/docs/usage-with-routing.md` -- `references/docs/extending-styles-using-styled-components.md` -- `references/docs/colors.md` -- `references/docs/i18n.md` -- `references/docs/deprecation-migration.md` -Deprecated components are marked in `index.md` and in each component file. +Use `index.md` to select a component, then open its file and only the relevant linked examples. + +Component files combine authored selection guidance with source-derived API details. Curated components link to selected examples and omit playgrounds; others retain generated Storybook examples. + +Load references only when needed: + +- `references/docs/usage.md`: package setup and basic use. +- `references/docs/installation.md`: installation and configuration. +- `references/docs/recommended-practices.md`: project-wide conventions. +- `references/docs/usage-with-routing.md`: routing integration. +- `references/docs/extending-styles-using-styled-components.md`: style extension. +- `references/docs/colors.md`: colours and tokens. +- `references/docs/i18n.md`: translation. +- `references/docs/deprecation-migration.md`: deprecation migration. + +Before finishing, confirm that imports and props match the component file and that deprecated APIs are either avoided or handled using the migration guidance. diff --git a/skills/carbon-react/components/pill.md b/skills/carbon-react/components/pill.md index 83d56c72b1..e7223eff46 100644 --- a/skills/carbon-react/components/pill.md +++ b/skills/carbon-react/components/pill.md @@ -5,6 +5,24 @@ description: Carbon Pill component props and usage examples. # Pill +A compact visual indicator that highlights information or status. + +## When to use + +- An item's status needs to be prominent at a glance; this is Pill's primary use. +- A short label helps categorise an item; this is secondary to communicating status. + +## Choose instead + +- **ButtonNext:** The element's primary purpose is to perform an action rather than display information or status. + +## Pitfalls + +- Give a removable pill a clear, unique accessible label that describes the relationship between the pill and its removal button. +- Use a left icon only with size L. +- Keep the Pill itself out of the tab order; only its removal button should receive keyboard focus. +- Add visually hidden context when the pill's meaning is not clear from its visible label and surroundings. + ## Import `import Pill from "carbon-react/lib/components/pill";` @@ -20,7 +38,7 @@ description: Carbon Pill component props and usage examples. | borderColor | string \| undefined | No | | | | Override color variant, provide any color from palette or any valid css color value. | | | fill | boolean \| undefined | No | | | | Fills the pill background with colour. When fill is false only the border is coloured. | false | | icon | React.ReactNode | No | | | | A React node displayed to the left of the pill content. Recommended for use with `size="L"` pills. | | -| inverse | boolean \| undefined | No | | | | Set to allow for inverse styling to be used on dark backgrounds. | false | +| inverse | boolean \| undefined | No | | | | Whether to apply inverse styling to the pill. | false | | m | ResponsiveValue \| undefined | No | | | | Margin on top, left, bottom and right | | | margin | ResponsiveValue \| undefined | No | | | | Margin on top, left, bottom and right | | | marginBottom | ResponsiveValue \| undefined | No | | | | Margin on bottom | | @@ -45,105 +63,11 @@ description: Carbon Pill component props and usage examples. | colorVariant | "warning" \| "neutral" \| "negative" \| "positive" \| "information" \| "neutralWhite" \| undefined | No | | Yes | Use `variant` prop instead. | Determines the colour variant of the pill. | | | isDarkBackground | boolean \| undefined | No | | Yes | Use `inverse` prop instead. | Apply inverse styling for use on dark backgrounds. | false | | pillRole | "tag" \| "status" \| undefined | No | | Yes | The pillRole prop is no longer used. Pill styling is determined by the `variant`, `fill`, and `inverse` props. | Sets the type of pill in use. | | -| size | "S" \| "M" \| "L" \| "XL" \| undefined | No | | Yes | The `XL` size is deprecated and will be removed in a future release. Use `L` instead. | Sets the size of the pill. | "M" | +| size | "S" \| "M" \| "L" \| "XL" \| undefined | No | | Values: "XL" | The `XL` size is deprecated and will be removed in a future release. Use `L` instead. | Sets the size of the pill. | "M" | ## Examples -### Playground - -**Args** - -```tsx -{ - children: "Label", - variant: "grey", - size: "M", - fill: true, - inverse: false, - onDelete: undefined, - icon: undefined, - } -``` - -**Render** - -```tsx -(args) => { - return ( - - {args.children} - - ); - } -``` - - -### Wrapped - -**Render** - -```tsx -() => { - return ( - - - Wrapped pill - - - Hyphe­nated­pill - - - ); -} -``` - - -### With Remove Button - -**Render** - -```tsx -() => { - const [isPillVisible, setIsPillVisible] = useState(true); - const hidePill = () => setIsPillVisible(false); - const showPill = () => setIsPillVisible(true); - return ( - <> - - {isPillVisible && Pill} - - ); -} -``` - - -### Inverse on Dark Background - -**Args** - -```tsx -{ - children: "Label", - variant: "blue", - size: "M", - onDelete: undefined, - icon: undefined, - } -``` - -**Render** - -```tsx -(args) => { - return ( - - - {args.children} - - - {args.children} - - - ); - } -``` +Load only the example needed for the current task; playground stories are intentionally omitted. +- [Wrapped](../examples/pill/wrapped.md) — Allow an unusually long label to wrap within a constrained width. +- [With Remove Button](../examples/pill/with-remove-button.md) — Make a pill removable with onDelete and give its removal button a clear, contextual accessible label. +- [Inverse](../examples/pill/inverse-on-dark-background.md) — Apply inverse styling to a pill. \ No newline at end of file diff --git a/skills/carbon-react/examples/pill/inverse-on-dark-background.md b/skills/carbon-react/examples/pill/inverse-on-dark-background.md new file mode 100644 index 0000000000..6c680498fa --- /dev/null +++ b/skills/carbon-react/examples/pill/inverse-on-dark-background.md @@ -0,0 +1,26 @@ +# Pill: Inverse + +```tsx +import Box from "carbon-react/lib/components/box"; +import Pill from "carbon-react/lib/components/pill"; + +export const PillInverseOnDarkBackgroundExample = () => { + const args = { + children: "Label", + variant: "blue", + size: "M", + onDelete: undefined, + icon: undefined, + }; + return ( + + + {args.children} + + + {args.children} + + + ); +}; +``` diff --git a/skills/carbon-react/examples/pill/with-remove-button.md b/skills/carbon-react/examples/pill/with-remove-button.md new file mode 100644 index 0000000000..14d7f895da --- /dev/null +++ b/skills/carbon-react/examples/pill/with-remove-button.md @@ -0,0 +1,29 @@ +# Pill: With Remove Button + +```tsx +import { useState } from "react"; +import Button from "carbon-react/lib/components/button/__next__"; +import Box from "carbon-react/lib/components/box"; +import Pill from "carbon-react/lib/components/pill"; + +export const PillWithRemoveButtonExample = () => { + const [isPillVisible, setIsPillVisible] = useState(true); + const hidePill = () => setIsPillVisible(false); + const showPill = () => setIsPillVisible(true); + return ( + <> + + + {isPillVisible && ( + + Ready to process + + )} + + + ); +}; +``` diff --git a/skills/carbon-react/examples/pill/wrapped.md b/skills/carbon-react/examples/pill/wrapped.md new file mode 100644 index 0000000000..cd7c056c86 --- /dev/null +++ b/skills/carbon-react/examples/pill/wrapped.md @@ -0,0 +1,19 @@ +# Pill: Wrapped + +```tsx +import Box from "carbon-react/lib/components/box"; +import Pill from "carbon-react/lib/components/pill"; + +export const PillWrappedExample = () => { + return ( + + + Wrapped pill + + + Hyphe­nated­pill + + + ); +}; +``` diff --git a/skills/carbon-react/index.md b/skills/carbon-react/index.md index 448839f255..da20fe889a 100644 --- a/skills/carbon-react/index.md +++ b/skills/carbon-react/index.md @@ -113,7 +113,7 @@ - [PicklistGroup](components/picklist-group.md) (deprecated) - [PicklistItem](components/picklist-item.md) (deprecated) - [PicklistPlaceholder](components/picklist-placeholder.md) (deprecated) -- [Pill](components/pill.md) +- [Pill](components/pill.md) — A compact visual indicator that highlights information or status. - [Pod](components/pod.md) (deprecated) - [PopoverContainer](components/popover-container.md) - [Portal](components/portal.md) diff --git a/src/components/pill/pill.component.tsx b/src/components/pill/pill.component.tsx index e89ae76ccb..5221cd6de4 100644 --- a/src/components/pill/pill.component.tsx +++ b/src/components/pill/pill.component.tsx @@ -37,7 +37,7 @@ export interface PillProps extends StyledPillProps, TagProps { * @deprecated Use `inverse` prop instead. */ isDarkBackground?: boolean; - /** Set to allow for inverse styling to be used on dark backgrounds. */ + /** Whether to apply inverse styling to the pill. */ inverse?: boolean; /** * A React node displayed to the left of the pill content. diff --git a/src/components/pill/pill.mdx b/src/components/pill/pill.mdx index 560db4f84c..8992a74b47 100644 --- a/src/components/pill/pill.mdx +++ b/src/components/pill/pill.mdx @@ -16,7 +16,7 @@ import * as PillStories from "./pill.stories"; Product Design System component -Compact visual indicators that help things in common stand out. +A compact visual indicator that highlights information or status. ## Contents @@ -67,13 +67,13 @@ language-specific and support for every `lang` attribute is not universal. #### With Remove Button -When a callback in the `onDelete` prop is provided, a remove button will be rendered and clicking on it will trigger that callback. Use the `ariaLabelOfRemoveButton` prop to customise the remove button's `aria-label` if needed. +When a callback in the `onDelete` prop is provided, a remove button will be rendered and clicking on it will trigger that callback. Use the `ariaLabelOfRemoveButton` prop to give the remove button a clear, unique accessible label that describes its relationship to the pill. -#### Inverse on Dark Background +#### Inverse -Use the `inverse` prop on dark surfaces so the pill remains readable and aligns with design guidance. +Use the `inverse` prop to apply inverse styling to the pill. diff --git a/src/components/pill/pill.stories.tsx b/src/components/pill/pill.stories.tsx index 44a14434b9..f403f52a3d 100644 --- a/src/components/pill/pill.stories.tsx +++ b/src/components/pill/pill.stories.tsx @@ -4,7 +4,7 @@ import { action } from "storybook/actions"; import generateStyledSystemProps from "../../../.storybook/utils/styled-system-props"; -import Button from "../button"; +import Button from "../button/__next__"; import Box from "../box"; import Icon from "../icon"; import Pill from "."; @@ -103,7 +103,16 @@ export const WithRemoveButton: Story = () => { return ( <> - {isPillVisible && Pill} + + {isPillVisible && ( + + Ready to process + + )} + ); }; @@ -130,4 +139,4 @@ export const InverseOnDarkBackground: Story = { icon: undefined, }, }; -InverseOnDarkBackground.storyName = "Inverse on Dark Background"; +InverseOnDarkBackground.storyName = "Inverse";