diff --git a/.npmignore b/.npmignore index c1db24f185..0423fc5faa 100644 --- a/.npmignore +++ b/.npmignore @@ -1,5 +1,7 @@ **/* !dist/** +!skills/** +!scripts/cli.js !CHANGELOG.md !package-lock.json !package.json diff --git a/README.md b/README.md index 6528efb001..c7e9de74de 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,24 @@ import { Button } from "@lifesg/react-design-system"; To see the full suite of components available, visit our [Storybook documentation](https://designsystem.life.gov.sg/react/index.html?path=/docs/getting-started-installation--docs). +## AI agent skill + +Give your AI coding assistant accurate knowledge of FDS components, import patterns, and rules — works with Claude Code, Cursor, GitHub Copilot, Codex, and other agents. + +**If you are starting fresh** — pull directly from GitHub: + +```bash +npx skills add lifesg/react-design-system +``` + +**If `@lifesg/react-design-system` is already installed in your project** — use the bin command, which registers the skill from your locally installed version: + +```bash +npx lifesg-react-design-system skills +``` + +The skill covers component selection, correct import paths, theming setup, layout patterns, and common mistakes to avoid. Re-run after upgrading the package. + ## Migrations If you are migrating from an older version of the Design System, you may refer to our [migration guides](https://github.com/LifeSG/react-design-system/wiki). diff --git a/package.json b/package.json index 5110e0f6fd..669ddc72ab 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ } }, "bin": { - "lifesg-react-design-system": "./codemods/run-codemod.js" + "lifesg-react-design-system": "./scripts/cli.js" }, "scripts": { "build": "npm run rollup", @@ -42,7 +42,8 @@ "lint:js:fix": "eslint '**/*.{ts,tsx,js,jsx}' --fix --max-warnings 0", "lint:css": "stylelint '**/*.css'", "lint:css:fix": "stylelint '**/*.css' --fix", - "test:eslint-rules": "node tests/eslint-rules/run-eslint-rule-tests.mjs" + "test:eslint-rules": "node tests/eslint-rules/run-eslint-rule-tests.mjs", + "generate:skill-props": "tsx scripts/generate-skill-props.ts" }, "repository": { "type": "git", diff --git a/rollup.config.js b/rollup.config.js index f085319bbc..bec0ee58c0 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -113,9 +113,7 @@ const plugins = [ main: "./cjs/index.js", module: "./index.js", types: "./index.d.ts", - bin: { - "lifesg-react-design-system": "./codemods/run-codemod.js", - }, + bin: pkg.bin, exports: { ".": { types: "./index.d.ts", @@ -306,7 +304,11 @@ const codemodBuildConfig = { tsconfig: "tsconfig.codemods.json", }), copy({ - targets: [{ src: "codemods/**/*", dest: "dist/codemods" }], + targets: [ + { src: "codemods/**/*", dest: "dist/codemods" }, + { src: "scripts/cli.js", dest: "dist/scripts" }, + { src: "skills/*", dest: "dist/skills" }, + ], }), ], }; diff --git a/scripts/cli.js b/scripts/cli.js new file mode 100644 index 0000000000..73c567b083 --- /dev/null +++ b/scripts/cli.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node +const { execSync, spawnSync } = require("child_process"); +const path = require("path"); + +const [, , subcommand, ...rest] = process.argv; + +if (subcommand === "skills") { + const packageRoot = path.resolve(__dirname, ".."); + execSync(`npx skills add ${packageRoot}`, { stdio: "inherit" }); +} else { + const codemodBin = path.resolve(__dirname, "../codemods/run-codemod.js"); + const result = spawnSync( + process.execPath, + [codemodBin, subcommand, ...rest].filter(Boolean), + { stdio: "inherit" } + ); + process.exit(result.status ?? 1); +} diff --git a/scripts/generate-skill-props.ts b/scripts/generate-skill-props.ts new file mode 100644 index 0000000000..581b2a139c --- /dev/null +++ b/scripts/generate-skill-props.ts @@ -0,0 +1,296 @@ +/** + * Generates ## Props sections in skill resource files from TypeScript source types. + * + * Run with: tsx scripts/generate-skill-props.ts + * + * For each component resource file under skills/fds-build/resources/, + * this script: + * 1. Reads the "Import:" line to find the source folder + * 2. Loads the corresponding src/.../types.ts via ts-morph + * 3. Flattens the interface hierarchy (skipping raw HTML/React base types) + * 4. Generates a markdown ## Props table + * 5. Inserts it before ## Rules (or ## Anti-patterns, or at the end) + * + * Re-run whenever @lifesg/react-design-system types change. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { + InterfaceDeclaration, + Project, + TypeAliasDeclaration, + type SourceFile, +} from "ts-morph"; + +import { PropExtractor, type PropEntry } from "../tools/shared/prop-extractor"; + +// ============================================================================= +// Constants +// ============================================================================= + +const ROOT_DIR = path.resolve(__dirname, ".."); +const SRC_DIR = path.join(ROOT_DIR, "src"); +const SKILLS_RESOURCES_DIR = path.join( + ROOT_DIR, + "skills", + "fds-build", + "resources" +); + +// ============================================================================= +// Source path resolution +// ============================================================================= + +/** Extract the import subfolder from the resource file's "Import:" line. */ +function extractImportFolder(content: string): string | null { + const match = content.match(/@lifesg\/react-design-system\/([a-z0-9-]+)/); + return match ? match[1] : null; +} + +/** + * Derive the source types.ts path for a resource file. + * Tries candidates in order and returns the first that exists on disk: + * 1. src/{importFolder}/{resourceName}/types.ts (nested — form-select under form/) + * 2. src/{importFolder}/types.ts (top-level — accordion, otp-verification) + * 3. src/{importFolder}s/types.ts (plural folder — animation → animations) + */ +function resolveSourceTypesPath( + resourceName: string, + importFolder: string +): string { + const candidates = [ + path.join(SRC_DIR, importFolder, resourceName, "types.ts"), + path.join(SRC_DIR, importFolder, "types.ts"), + path.join(SRC_DIR, `${importFolder}s`, "types.ts"), + ]; + return candidates.find(fs.existsSync) ?? candidates[0]; +} + +// ============================================================================= +// Markdown generation +// ============================================================================= + +function escapeCell(s: string): string { + return s.replace(/\|/g, "\\|"); +} + +function generatePropsTable(props: PropEntry[]): string { + if (props.length === 0) return ""; + + const header = [ + "| Prop | Type | Default | Description |", + "| ---- | ---- | ------- | ----------- |", + ]; + + const rows = props.map((p) => { + const namePart = p.required ? `\`${p.name}\` \\*` : `\`${p.name}\``; + return `| ${namePart} | \`${escapeCell(p.type)}\` | ${ + p.defaultVal || "—" + } | ${escapeCell(p.description) || "—"} |`; + }); + + return [...header, ...rows].join("\n"); +} + +/** Build the full ## Props content to insert. Supports multiple interfaces (sub-sections). */ +function buildPropsContent( + interfaces: Array<{ title: string; props: PropEntry[] }> +): string { + if (interfaces.length === 0) return ""; + + const sections = interfaces + .filter((i) => i.props.length > 0) + .map((i) => `${i.title}\n\n${generatePropsTable(i.props)}`); + + if (sections.length === 0) return ""; + return sections.join("\n\n"); +} + +// ============================================================================= +// Resource file processing +// ============================================================================= + +export function stripPropsSection(content: string): string { + const lines = content.split("\n"); + const out: string[] = []; + let inProps = false; + for (const line of lines) { + if (/^## Props(\b|$)/.test(line)) { + inProps = true; + continue; + } + if (inProps && /^## /.test(line)) { + inProps = false; + } + if (!inProps) out.push(line); + } + return out.join("\n").replace(/\n{3,}/g, "\n\n"); +} + +export function insertPropsSection( + content: string, + propsBlock: string +): string { + const stripped = stripPropsSection(content); + const marker = "\n\n" + propsBlock; + + if (stripped.includes("\n## Rules")) { + return stripped.replace("\n## Rules", marker + "\n\n## Rules"); + } + if (stripped.includes("\n## Anti-patterns")) { + return stripped.replace( + "\n## Anti-patterns", + marker + "\n\n## Anti-patterns" + ); + } + return stripped.trimEnd() + marker + "\n"; +} + +function processResourceFile( + filePath: string, + project: Project +): { skipped: boolean; reason?: string } { + const content = fs.readFileSync(filePath, "utf-8"); + + const importFolder = extractImportFolder(content); + if (!importFolder) return { skipped: true, reason: "no Import: line" }; + + const resourceName = path.basename(filePath, ".md"); + const sourceTypesPath = resolveSourceTypesPath(resourceName, importFolder); + + if (!fs.existsSync(sourceTypesPath)) { + return { + skipped: true, + reason: `no source: ${path.relative(ROOT_DIR, sourceTypesPath)}`, + }; + } + + let sourceFile: SourceFile | undefined; + try { + sourceFile = + project.getSourceFile(sourceTypesPath) ?? + project.addSourceFileAtPathIfExists(sourceTypesPath); + } catch { + return { skipped: true, reason: "ts-morph load failed" }; + } + + if (!sourceFile) { + return { skipped: true, reason: "source file not found by ts-morph" }; + } + + // Collect interfaces and type aliases that have a JSDoc description (public API intent) + const hasJsDoc = (n: InterfaceDeclaration | TypeAliasDeclaration) => + n.getJsDocs().some((d) => d.getCommentText()); + + const nodesFromFile = (sf: SourceFile) => [ + ...sf.getInterfaces().filter((i) => i.isExported() && hasJsDoc(i)), + ...sf.getTypeAliases().filter((t) => t.isExported() && hasJsDoc(t)), + ]; + + let publicNodes: Array = + nodesFromFile(sourceFile); + + // Barrel files (e.g. form/form-otp-verification/types.ts) only re-export from + // another file. Follow named re-exports one level deep. + if (publicNodes.length === 0) { + for (const exportDecl of sourceFile.getExportDeclarations()) { + const reFile = exportDecl.getModuleSpecifierSourceFile(); + if (!reFile) continue; + const exportedNames = new Set( + exportDecl.getNamedExports().map((s) => s.getName()) + ); + const candidates = nodesFromFile(reFile).filter((n) => + exportedNames.has(n.getName()) + ); + publicNodes = publicNodes.concat(candidates); + } + } + + if (publicNodes.length === 0) { + return { skipped: true, reason: "no documented exported interfaces" }; + } + + const propExtractor = new PropExtractor(); + + // Build interface sections + const ifaceSections = publicNodes.map((node) => { + const props = propExtractor.collectProps(node); + const title = + publicNodes.length > 1 + ? `## Props — \`${node.getName()}\`` + : "## Props"; + return { title, props }; + }); + + const propsBlock = buildPropsContent(ifaceSections); + if (!propsBlock) return { skipped: true, reason: "no props extracted" }; + + const newContent = insertPropsSection(content, propsBlock); + fs.writeFileSync(filePath, newContent, "utf-8"); + + return { skipped: false }; +} + +// ============================================================================= +// Main +// ============================================================================= + +function main() { + const project = new Project({ + tsConfigFilePath: path.join(ROOT_DIR, "tsconfig.json"), + skipAddingFilesFromTsConfig: true, + }); + + const resourceDirs = [path.join(SKILLS_RESOURCES_DIR, "components")]; + + let updated = 0; + let skipped = 0; + const updatedPaths: string[] = []; + + for (const dir of resourceDirs) { + if (!fs.existsSync(dir)) continue; + + for (const file of fs.readdirSync(dir).sort()) { + if (!file.endsWith(".md")) continue; + const filePath = path.join(dir, file); + const { skipped: wasSkipped, reason } = processResourceFile( + filePath, + project + ); + + if (wasSkipped) { + skipped++; + if (process.env.VERBOSE) { + console.log( + ` skip ${file}${reason ? ` (${reason})` : ""}` + ); + } + } else { + updated++; + updatedPaths.push(filePath); + console.log(` props ${file}`); + } + } + } + + if (updatedPaths.length > 0) { + const { execSync } = require("child_process"); + execSync( + `npx prettier --write ${updatedPaths + .map((p) => `"${p}"`) + .join(" ")}`, + { + stdio: "inherit", + cwd: ROOT_DIR, + } + ); + } + + console.log(`\nDone. Updated: ${updated}, Skipped: ${skipped}`); +} + +if (require.main === module) { + main(); +} diff --git a/skills/fds-build/SKILL.md b/skills/fds-build/SKILL.md new file mode 100644 index 0000000000..650f1be8f3 --- /dev/null +++ b/skills/fds-build/SKILL.md @@ -0,0 +1,167 @@ +--- +name: "fds-build" +description: "Use when building with @lifesg/react-design-system v4. Covers composition rules, correct import patterns, token usage, and component discovery." +metadata: + version: "4.0.0-alpha" + audience: external + category: design-system +--- + +# Flagship Design System — Agent Skill + +You are helping build web applications with **`@lifesg/react-design-system`** for government digital products. + +--- + +## Install + +```bash +npm install @lifesg/react-design-system @lifesg/react-icons @floating-ui/react +``` + +Do not install `styled-components` — v4 uses CSS Modules for custom styles. + +Peer deps: `react` + `react-dom` (^17, ^18, or ^19). + +**If the project is new or missing setup** — read `./resources/setup/setup.md` for the full CSS/ThemeProvider/Vite walkthrough before writing any component code. + +--- + +## ThemeProvider + +Wrap your app in `ThemeProvider` to apply the correct theme CSS variables and enable dark/light mode. + +```tsx +import { ThemeProvider } from "@lifesg/react-design-system/theme"; +import "@lifesg/react-design-system/theme/styles/lifesg.css"; + +export default function App() { + return {/* your app */}; +} +``` + +Theme is a **string**: `"lifesg"` · `"bookingsg"` · `"ccube"` · `"mylegacy"` · `"oneservice"` · `"pa"` · `"supportgowhere"` · `"sgw-digital-lobby"` · `"careercompass"` · `"rbs"` · `"imda"` · `"spf"` · `"smgs"` · `"a11y-playground"` + +Dark/light mode: `mode` prop defaults to `"auto"` (OS preference). Override with `mode="light"` or `mode="dark"`. + +--- + +## Component imports + +Always use subpath imports: + +```tsx +import { Button } from "@lifesg/react-design-system/button"; +import { Form } from "@lifesg/react-design-system/form"; +``` + +--- + +## Page structure + +Every page must have `Navbar` at the top and `Footer` at the bottom. + +```tsx +import { Navbar } from "@lifesg/react-design-system/navbar"; +import { Footer } from "@lifesg/react-design-system/footer"; + +; +{ + /* page content */ +} +