#!/usr/bin/env node /** * Generates Agent Skills for React Spectrum (S2), migration, and React Aria. * * This script creates skills in the Agent Skills format (https://agentskills.io/specification) * * Usage: * node packages/dev/s2-docs/scripts/generateAgentSkills.mjs. * * The script will: * 1. Run the markdown docs generation if dist doesn't exist * 2. Create .well-known/skills directories inside the docs dist output * 3. Copy relevant documentation to references/ subdirectories * 4. Generate .well-known/skills/index.json for discovery. */ import {execSync} from 'child_process'; import {fileURLToPath} from 'url'; import fs from 'fs'; import path from 'path'; import remarkParse from 'remark-parse'; import {unified} from 'unified'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '../../../../'); const MARKDOWN_DOCS_DIST = path.join(REPO_ROOT, 'packages/dev/s2-docs/dist'); const MDX_PAGES_DIR = path.join(REPO_ROOT, 'packages/dev/s2-docs/pages'); const MARKDOWN_DOCS_SCRIPT = path.join(__dirname, 'generateMarkdownDocs.mjs'); const MIGRATION_REFS_DIR = path.join(REPO_ROOT, 'packages/dev/s2-docs/migration-references'); const AUDIT_SKILL_SOURCE_DIR = path.join(REPO_ROOT, 'packages/dev/s2-docs/skills/spectrum-audit'); const WELL_KNOWN_DIR = '.well-known'; const WELL_KNOWN_SKILLS_DIR = 'skills'; // Skill definitions const SKILLS = { 'react-spectrum-s2': { name: 'react-spectrum-s2', description: "Build UIs with React Spectrum S2 (Spectrum 2), Adobe's component library for React. Use when developers are using `@react-spectrum/s2` or need Adobe design system components. Includes React Aria Components docs as a reference for building custom components on top of unstyled primitives.", license: 'Apache-2.0', sourceDir: 's2', additionalReferenceLibraries: [ { skillName: 'react-aria', referenceSubdir: 'react-aria', title: 'React Aria Components', description: 'Documentation for unstyled accessible primitives. Use only when no React Spectrum S2 component fits the requirements.' } ], compatibility: 'Requires a React project with @react-spectrum/s2 installed.', metadata: { author: 'Adobe', website: 'https://react-spectrum.adobe.com/' } }, 'migrate-react-spectrum-v3-to-s2': { name: 'migrate-react-spectrum-v3-to-s2', description: 'Upgrade React Spectrum v3 (Spectrum 1) codebases to React Spectrum S2. Use when developers mention migrating or upgrading from React Spectrum v3, Spectrum 1, S1, @adobe/react-spectrum, @react-spectrum/* packages, or codemod-assisted upgrades to @react-spectrum/s2.', kind: 'migration', license: 'Apache-2.0', sourceDir: 's2', compatibility: 'Requires a React project currently using React Spectrum v3, @react-spectrum/* packages, or related React Spectrum v3 helpers.', metadata: { author: 'Adobe', website: 'https://react-spectrum.adobe.com/' } }, 'react-aria': { name: 'react-aria', description: 'Build accessible UI components with React Aria Components. Use when developers mention React Aria, react-aria-components, accessible components, or need unstyled accessible primitives. Provides documentation for building custom accessible UI with hooks and components.', license: 'Apache-2.0', sourceDir: 'react-aria', compatibility: 'Requires React project with react-aria-components installed.', metadata: { author: 'Adobe', website: 'https://react-aria.adobe.com/' } }, 'spectrum-audit': { name: 'spectrum-audit', description: 'Audit a codebase for adherence to the Spectrum design system and React Spectrum S2 best practices. Use when a developer asks to audit, review, lint, or check a project for Spectrum/S2 correctness, configuration, styling, accessibility, or component-usage issues.', kind: 'audit', license: 'Apache-2.0', sourceDir: 's2', compatibility: 'Requires a React project using @react-spectrum/s2.', metadata: { author: 'Adobe', website: 'https://react-spectrum.adobe.com/' } } }; const CUSTOM_SKILL_CONTENT = { 'react-spectrum-s2': { skillNotesMarkdown: [ 'If the requirements do not clearly specify which React Spectrum component to use, consult the [Component Decision Tree](references/guides/component-decision-tree.md) before choosing a component.', 'If the request involves a Figma design, frame, or URL — or if the Figma MCP (`get_design_context`,`search_design_system`, etc.) is available — consult [Implementing Figma designs with React Spectrum S2](references/guides/figma-to-s2.md) before generating code.', 'When writing tests that exercise S2 components, consult [Testing with React Spectrum S2](references/guides/test-utils-guidance.md) and prefer the ARIA pattern testers from `@react-spectrum/test-utils` over hand-rolled role/selector queries.', `## React Spectrum S2 vs React Aria Components React Spectrum S2 is built on top of React Aria Components. The S2 components add Spectrum 2 styling, behavior, and slot structure on top of the unstyled React Aria primitives. Always prefer S2 components for React Spectrum work because they are pre-styled, design-system compliant, and cover most common UI patterns. Only reach for React Aria Components directly when: - Building a custom component because no S2 component matches the requirements. Follow [Creating Custom Components](references/guides/creating-custom-components.md) and pair the React Aria primitive with the S2 \`style\` macro for Spectrum styling. - You need a utility such as \`FocusScope\`, \`VisuallyHidden\`, \`useFocusRing\`, \`mergeProps\`, etc. The React Aria Components documentation is bundled under \`references/react-aria/\`. Many unstyled React Aria Components share the same name as S2 components, so ensure that you're searching and accessing the correct docs where needed.` ].join('\n\n'), embeddedMarkdownPaths: [ path.join( REPO_ROOT, 'packages/dev/s2-docs/skills/react-spectrum-s2/implementation-guidance.md' ) ], guideEntries: [ { title: 'Component Decision Tree', path: 'component-decision-tree.md', sourcePath: path.join( REPO_ROOT, 'packages/dev/s2-docs/skills/react-spectrum-s2/component-decision-tree.md' ), description: 'How to choose the right S2 component when requirements do not name one explicitly.' }, { title: 'Implementing Figma designs with React Spectrum S2', path: 'figma-to-s2.md', sourcePath: path.join( REPO_ROOT, 'packages/dev/s2-docs/skills/react-spectrum-s2/figma-to-s2.md' ), description: 'How to translate Figma designs (via the Figma MCP) into S2 components and the `style` macro.' }, { title: 'Creating Custom Components', path: 'creating-custom-components.md', sourcePath: path.join( REPO_ROOT, 'packages/dev/s2-docs/skills/react-spectrum-s2/creating-custom-components.md' ), description: 'How to build custom Spectrum 2 components using React Aria Components and the `style` macro.' }, { title: 'Testing with React Spectrum S2', path: 'test-utils-guidance.md', sourcePath: path.join( REPO_ROOT, 'packages/dev/s2-docs/skills/react-spectrum-s2/test-utils-guidance.md' ), description: 'How to write tests for S2 components using ARIA pattern testers from `@react-spectrum/test-utils`.' } ] }, 'react-aria': { embeddedMarkdownPaths: [ path.join(REPO_ROOT, 'packages/dev/s2-docs/skills/react-aria/test-utils-guidance.md') ] } }; /** * Ensure markdown docs are generated. */ function ensureMarkdownDocs() { const s2LlmsTxt = path.join(MARKDOWN_DOCS_DIST, 's2', 'llms.txt'); const reactAriaLlmsTxt = path.join(MARKDOWN_DOCS_DIST, 'react-aria', 'llms.txt'); if (!fs.existsSync(s2LlmsTxt) || !fs.existsSync(reactAriaLlmsTxt)) { console.log('Markdown docs not found. Running generateMarkdownDocs.mjs...'); execSync(`node "${MARKDOWN_DOCS_SCRIPT}"`, { cwd: REPO_ROOT, stdio: 'inherit' }); } } function getWellKnownRootForLibrary(sourceDir) { return path.join(MARKDOWN_DOCS_DIST, sourceDir, WELL_KNOWN_DIR, WELL_KNOWN_SKILLS_DIR); } function getCustomSkillContent(skillName) { return CUSTOM_SKILL_CONTENT[skillName] ?? null; } function renderCustomMarkdown(markdownPath, replacements = {}) { let content = fs.readFileSync(markdownPath, 'utf8'); for (const [token, value] of Object.entries(replacements)) { content = content.replaceAll(token, value); } return content.trim(); } function readCustomEmbeddedMarkdown(skillName, replacements = {}) { const customContent = getCustomSkillContent(skillName); if (!customContent?.embeddedMarkdownPaths?.length) { return ''; } return customContent.embeddedMarkdownPaths .flatMap(markdownPath => { if (!fs.existsSync(markdownPath)) { console.warn(`Custom skill content not found at ${markdownPath}`); return []; } return [renderCustomMarkdown(markdownPath, replacements)]; }) .filter(Boolean) .join('\n\n'); } function getCustomGuideEntries(skillName) { return getCustomSkillContent(skillName)?.guideEntries ?? []; } function getCustomSkillNotesMarkdown(skillName) { return getCustomSkillContent(skillName)?.skillNotesMarkdown ?? ''; } function getAdditionalReferenceLibraries(skillConfig) { return (skillConfig.additionalReferenceLibraries ?? []).flatMap(library => { const referencedSkillConfig = SKILLS[library.skillName]; if (!referencedSkillConfig) { console.warn( `Additional reference skill "${library.skillName}" not found for ${skillConfig.name}` ); return []; } return [ { ...library, skillConfig: referencedSkillConfig } ]; }); } /** * Parse llms.txt to get documentation entries. */ function parseLlmsTxt(llmsTxtPath) { const content = fs.readFileSync(llmsTxtPath, 'utf8'); const entries = []; const tree = unified().use(remarkParse).parse(content); const toText = node => { if (!node) { return ''; } if (node.type === 'text') { return node.value; } if (node.type === 'inlineCode') { return '`' + node.value + '`'; } if (Array.isArray(node.children)) { return node.children.map(toText).join(''); } return ''; }; const extractEntry = listItem => { const paragraph = listItem.children?.find(child => child.type === 'paragraph'); if (!paragraph || !Array.isArray(paragraph.children)) { return null; } const linkIndex = paragraph.children.findIndex(child => child.type === 'link'); if (linkIndex === -1) { return null; } const link = paragraph.children[linkIndex]; const title = toText(link).trim(); const entryPath = link.url; if (!title || !entryPath) { return null; } let description = paragraph.children .slice(linkIndex + 1) .map(toText) .join('') .trim(); if (description.startsWith(':')) { description = description.slice(1).trim(); } return { title, path: entryPath, description }; }; const walk = node => { if (!node || !Array.isArray(node.children)) { return; } for (const child of node.children) { if (child.type === 'listItem') { const entry = extractEntry(child); if (entry) { entries.push(entry); } } walk(child); } }; walk(tree); return entries; } /** * Extract the section export from an MDX file. * * @param {string} mdxPath - Path to the MDX file. * @returns {string | null} - The section value or null if not found */ function extractSectionFromMdx(mdxPath) { if (!fs.existsSync(mdxPath)) { return null; } const content = fs.readFileSync(mdxPath, 'utf8'); const sectionMatch = content.match(/export\s+const\s+section\s*=\s*['"]([^'"]+)['"]/); return sectionMatch ? sectionMatch[1] : null; } /** * Get the MDX file path for a given entry. * * @param {string} sourceDir - The source directory (e.g., "s2" or "react-aria") * @param {string} entryPath - The path from llms.txt (e.g., "Button.md") * @returns {string} - The full path to the MDX file */ function getMdxPath(sourceDir, entryPath) { // Convert .md path to .mdx path (e.g., "Button.md" -> "Button.mdx") const mdxRelPath = entryPath.replace(/\.md$/, '.mdx'); return path.join(MDX_PAGES_DIR, sourceDir, mdxRelPath); } /** * Map section names to category keys. */ const SECTION_TO_CATEGORY = { // Guides Guides: 'guides', Overview: 'guides', Reference: 'guides', 'Getting started': 'guides', // Components (default, no section export) Components: 'components', // Utilities Utilities: 'utilities', // Interactions (hooks) Interactions: 'interactions', // Releases Releases: 'releases', // Blog Blog: 'blog', // Examples Examples: 'examples', // Internationalized 'Date and Time': 'internationalized', Numbers: 'internationalized' }; /** * Files to filter out per source directory. */ const FILTERED_FILES = { s2: ['index.md', 'error.md'], 'react-aria': ['index.md', 'examples/index.md', 'error.md'] }; /** * Categorize documentation entries by reading section exports from MDX files. */ function categorizeEntries(entries, sourceDir) { const categories = { components: [], guides: [], utilities: [], interactions: [], releases: [], blog: [], examples: [], testing: [], internationalized: [] }; const filteredFiles = FILTERED_FILES[sourceDir] || []; for (const entry of entries) { // Filter out specific files per source directory if (filteredFiles.includes(entry.path)) { continue; } // Skip malformed entries if (entry.title.length > 100) { continue; } // Check if this is a testing subpage (e.g., "CheckboxGroup/testing.md") if (entry.path.includes('/testing.md')) { categories.testing.push(entry); continue; } // Get the section from the original MDX file const mdxPath = getMdxPath(sourceDir, entry.path); const section = extractSectionFromMdx(mdxPath); if (section) { const categoryKey = SECTION_TO_CATEGORY[section]; if (categoryKey && categories[categoryKey]) { categories[categoryKey].push(entry); } else { // Unknown section, default to components console.warn(`Unknown section "${section}" for ${entry.path}, defaulting to components`); categories.components.push(entry); } } else { // No section export means it's a component categories.components.push(entry); } } return categories; } function generateFrontmatter(skillConfig) { return `--- name: "${skillConfig.name}" description: "${skillConfig.description}" license: "${skillConfig.license}" compatibility: "${skillConfig.compatibility}" metadata: author: "${skillConfig.metadata.author}" website: "${skillConfig.metadata.website}" --- `; } /** * Generate the SKILL.md content. */ function generateDocsSkillMd(skillConfig, categories, isS2) { const customGuideEntries = getCustomGuideEntries(skillConfig.name); const customSkillNotesMarkdown = getCustomSkillNotesMarkdown(skillConfig.name); const additionalReferenceLibraries = getAdditionalReferenceLibraries(skillConfig); const embeddedCustomMarkdown = readCustomEmbeddedMarkdown(skillConfig.name, { '{{guidesBase}}': 'references/guides/', '{{componentsBase}}': 'references/components/', '{{testingBase}}': 'references/testing/' }); let content = generateFrontmatter(skillConfig); if (isS2) { content += '# React Spectrum S2 (Spectrum 2)\n'; } else { content += '# React Aria Components\n'; } if (customSkillNotesMarkdown) { content += `\n${customSkillNotesMarkdown}\n`; } if (embeddedCustomMarkdown) { content += `\n${embeddedCustomMarkdown}\n\n`; } content += `## Documentation Structure The \`references/\` directory contains detailed documentation organized as follows: `; // Add documentation sections if (customGuideEntries.length > 0 || categories.guides.length > 0) { content += `### Guides `; for (const entry of customGuideEntries) { content += `- [${entry.title}](references/guides/${entry.path})${entry.description ? `: ${entry.description}` : ''}\n`; } for (const entry of categories.guides) { content += `- [${entry.title}](references/guides/${entry.path})\n`; } content += '\n'; } if (categories.components.length > 0) { const componentNames = categories.components.map(entry => entry.title).join(', '); content += `### Components Component documentation is in \`references/components/\` — one Markdown file per component (e.g. \`references/components/Button.md\`). Read the file for a component when you need its API, props, examples, or accessibility notes. Available components: ${componentNames}. `; } if (categories.interactions.length > 0) { content += `### Interactions `; for (const entry of categories.interactions) { content += `- [${entry.title}](references/interactions/${entry.path})${entry.description ? `: ${entry.description}` : ''}\n`; } content += '\n'; } if (categories.utilities.length > 0) { content += `### Utilities `; for (const entry of categories.utilities) { content += `- [${entry.title}](references/utilities/${entry.path})${entry.description ? `: ${entry.description}` : ''}\n`; } content += '\n'; } if (categories.internationalized.length > 0) { content += `### Internationalization `; for (const entry of categories.internationalized) { // Strip the 'internationalized/' prefix to avoid double-nesting in the path let refPath = entry.path; if (refPath.startsWith('internationalized/')) { refPath = refPath.slice('internationalized/'.length); } content += `- [${entry.title}](references/internationalized/${refPath})\n`; } content += '\n'; } if (additionalReferenceLibraries.length > 0) { content += `### Additional References `; for (const library of additionalReferenceLibraries) { content += `- [${library.title}](references/${library.referenceSubdir}/llms.txt)${library.description ? `: ${library.description}` : ''}\n`; } content += '\n'; } return content.trimEnd() + '\n'; } function generateMigrationSkillMd(skillConfig) { return ( `${generateFrontmatter(skillConfig)}# React Spectrum v3 to S2 migration Upgrade React Spectrum v3 codebases to S2 by following these eight steps in order. ## Scope This skill covers only the React Spectrum v3 (S1) to S2 migration. Do **not** perform major dependency upgrades such as React version bumps (e.g. React 16→17, 17→18, 18→19) as part of this migration. If the project needs a major dependency upgrade, note it as a recommended follow-up in the final report (Step 8) rather than attempting it during migration. ## Step 1: Inspect the codebase - Search package manifests for \`@adobe/react-spectrum\`, \`@react-spectrum/*\`, and \`@spectrum-icons/*\`. - Note the package manager (npm, yarn, pnpm) from the lockfile. - Identify the bundler used by the migration target (Parcel, Vite, webpack, Next.js, Rollup, ESBuild). - In monorepos, inspect the specific package or app being migrated rather than the workspace root. - Find app entrypoints, root providers, shared test wrappers, toast setup, and any \`defaultTheme\` usage. See [Prerequisites](references/focused-prerequisites.md) for the full inspection checklist and minimum tool versions. ## Step 2: Install @react-spectrum/s2 Install the S2 package with the project's package manager: \`\`\`bash npm install @react-spectrum/s2 yarn add @react-spectrum/s2 pnpm add @react-spectrum/s2 \`\`\` If the bundler is not Parcel v2.12.0+, also install and configure \`unplugin-parcel-macros\` as a dev dependency. See [Getting started](references/docs-getting-started.md) for bundler-specific setup instructions. ## Step 3: Dry-run the codemod Preview what the codemod will change before applying: \`\`\`bash npx @react-spectrum/codemods s1-to-s2 --agent --dry yarn dlx @react-spectrum/codemods s1-to-s2 --agent --dry pnpm dlx @react-spectrum/codemods s1-to-s2 --agent --dry \`\`\` Use \`npx\` for npm/Yarn 1, \`yarn dlx\` for Yarn Berry/PnP, \`pnpm dlx\` for pnpm. Add \`--path