Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
**/*
!dist/**
!skills/**
!scripts/cli.js
!CHANGELOG.md
!package-lock.json
!package.json
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
}
},
"bin": {
"lifesg-react-design-system": "./codemods/run-codemod.js"
"lifesg-react-design-system": "./scripts/cli.js"
Comment thread
qroll marked this conversation as resolved.
},
"scripts": {
"build": "npm run rollup",
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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" },
],
}),
],
};
Expand Down
18 changes: 18 additions & 0 deletions scripts/cli.js
Original file line number Diff line number Diff line change
@@ -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);
}
296 changes: 296 additions & 0 deletions scripts/generate-skill-props.ts
Comment thread
ghazwan-gt marked this conversation as resolved.
Comment thread
qroll marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
ghazwan-gt marked this conversation as resolved.

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<InterfaceDeclaration | TypeAliasDeclaration> =
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();
}
Loading
Loading