diff --git a/packages/tokens/.eslintignore b/packages/tokens/.eslintignore new file mode 100644 index 0000000..76add87 --- /dev/null +++ b/packages/tokens/.eslintignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/packages/tokens/.eslintrc.json b/packages/tokens/.eslintrc.json new file mode 100644 index 0000000..1cce5d0 --- /dev/null +++ b/packages/tokens/.eslintrc.json @@ -0,0 +1,16 @@ +{ + "root": true, + "extends": [ + "custom" + ], + "parserOptions": { + "project": [ + "./tsconfig.json", + "./tsconfig.node.json" + ] + }, + "rules": { + "no-console": "off", + "no-eval": "off" + } +} \ No newline at end of file diff --git a/packages/tokens/README.md b/packages/tokens/README.md new file mode 100644 index 0000000..cfcff26 --- /dev/null +++ b/packages/tokens/README.md @@ -0,0 +1,88 @@ +# @openfun/cunningham-tokens + +## Introduction + +**What are design tokens ?** + +Design tokens are the fundamental variables defining the precise behavior and rendering of ui components. + +For example: +- The primary color of a text element +- The standard spacing between two elements +- The border radius of a button +- ... + +## Getting started + +In this section we will install the library and generate the file that contains the design tokens of your app in order to +make Cunningham's Design System yours! + +Install the lib + +``` +yarn add @openfun/cunningham-tokens +``` + +Create a file named `cunningham.cjs` at the root of your project + +``` +module.exports = { + theme: { + colors: { + primary: 'purple' + }, + }, +}; +``` + +In this configuration file you can overwrite all the default values of the design system. +You can find the default values [here](./src/bin/cunningham.dist.js). + +Now add this script to your `package.json` + +``` +{ + "scripts": { + ... + "build-theme": "cunningham" + } +} +``` + +The cunningham CLI's main purpose is to build a ad-hoc CSS file that contains all your customized design tokens, +by taking into account your local configuration ( defined in the file that you previously created : `cunningham.cjs`, +it is worth mentioning that this file is optional, hence it will generate a file containing the default values of the +design system ) + +> You can run `yarn run cunningham -h` to see the available options. + +And in order to generate the tokens css file, run + +``` +yarn build-theme +``` + +It will generate a file named `cunningham-tokens.css`. **Don't forget to run this command everytime you +change the content of the `cunningham.cjs` file !** + +Then, add these lines at the top of your main stylesheet file: + +``` +@import "cunningham-tokens"; // Imports the file you just generated. +@import "@openfun/cunningham-react/style"; +``` + +It's all done! + +## Use the design tokens + +Design tokens variable are all present in the `cunningham-tokens.css` file. They are all prefixed with `--c` in order to +avoid collision. + +Here is an example to make the text's color renders with the value of the primary color in `.my-element` matching elements + +``` +.my-element { + color: var(--c--colors--primary); +} +``` \ No newline at end of file diff --git a/packages/tokens/jest.config.ts b/packages/tokens/jest.config.ts new file mode 100644 index 0000000..27cf011 --- /dev/null +++ b/packages/tokens/jest.config.ts @@ -0,0 +1,4 @@ +export default { + preset: "ts-jest", + testEnvironment: "node", +}; diff --git a/packages/tokens/package.json b/packages/tokens/package.json new file mode 100644 index 0000000..17a1400 --- /dev/null +++ b/packages/tokens/package.json @@ -0,0 +1,43 @@ +{ + "name": "@openfun/cunningham-tokens", + "private": false, + "version": "0.0.0", + "license": "MIT", + "bin": { + "cunningham": "dist/bin/Main.js" + }, + "exports": { + "./default-tokens": "./dist/cunningham-tokens.css" + }, + "files": [ + "dist/" + ], + "scripts": { + "lint": "eslint . 'src/**/*.{ts,tsx}'", + "dev": "nodemon --watch 'src/bin' --ext '*' --exec 'yarn build'", + "build": "tsc -p tsconfig.json && cp src/bin/cunningham.dist.js dist/bin && chmod +x dist/bin/Main.js && yarn build-default-theme", + "build-default-theme": "./dist/bin/Main.js -o dist -s html", + "test": "FORCE_COLOR=1 jest --verbose src/bin/tests" + }, + "dependencies": { + "chalk": "4.1.2", + "commander": "9.4.1", + "deepmerge": "4.2.2", + "figlet": "1.5.2" + }, + "devDependencies": { + "@types/figlet": "1.5.5", + "@types/jest": "29.2.3", + "@types/node": "18.11.9", + "eslint-config-custom": "*", + "jest": "29.3.1", + "nodemon": "2.0.20", + "prettier": "2.8.0", + "ts-jest": "29.0.3", + "ts-node": "10.9.1", + "typescript": "4.9.3" + }, + "engines": { + "node": ">=16.0.0" + } +} diff --git a/packages/tokens/src/bin/Config.ts b/packages/tokens/src/bin/Config.ts new file mode 100644 index 0000000..087bd9b --- /dev/null +++ b/packages/tokens/src/bin/Config.ts @@ -0,0 +1,8 @@ +export default { + configurationFilenames: ["cunningham.js", "cunningham.cjs"], + sass: { + varSeparator: "--", + varPrefix: "c--", + tokenFilenameCss: "cunningham-tokens.css", + }, +}; diff --git a/packages/tokens/src/bin/ConfigLoader.ts b/packages/tokens/src/bin/ConfigLoader.ts new file mode 100644 index 0000000..9eaf2f4 --- /dev/null +++ b/packages/tokens/src/bin/ConfigLoader.ts @@ -0,0 +1,32 @@ +import path from "path"; +import * as fs from "fs"; +import deepmerge from "deepmerge"; +import Config from "./Config"; +import { ConfigShape } from "./TokensGenerator"; +import { workPath } from "./Paths"; + +const getLocalConfig = async () => { + const filename = Config.configurationFilenames + .map((filename_) => path.join(workPath(), filename_)) + .find((filename_) => fs.existsSync(filename_)); + + if (!filename) { + console.log("No local config found, using default config."); + return {}; + } + + const config = await import(filename); + return config.default; +}; + +const getDistConfig = async () => { + const config = await import("./cunningham.dist.js"); + return config.default; +}; + +export const getConfig = async () => { + const localConfig = await getLocalConfig(); + const distConfig = await getDistConfig(); + const config: ConfigShape = deepmerge(distConfig, localConfig); + return config; +}; diff --git a/packages/tokens/src/bin/CssGenerator.ts b/packages/tokens/src/bin/CssGenerator.ts new file mode 100644 index 0000000..4561a6f --- /dev/null +++ b/packages/tokens/src/bin/CssGenerator.ts @@ -0,0 +1,26 @@ +import * as path from "path"; +import * as fs from "fs"; +import chalk from "chalk"; +import Config from "./Config"; +import { flatify } from "./Utils/Flatify"; + +export const cssGenerator = async ( + tokens: any, + opts: { path: string; selector: string } +) => { + const flatTokens = flatify(tokens, Config.sass.varSeparator); + const cssVars = Object.keys(flatTokens).reduce((acc, token) => { + return ( + acc + `\t--${Config.sass.varPrefix}${token}: ${flatTokens[token]};\n` + ); + }, ""); + const cssContent = `${opts.selector} {\n${cssVars}}`; + + const dest = path.join(opts.path, Config.sass.tokenFilenameCss); + console.log("Generating tokens file to " + dest + " ..."); + if (!fs.existsSync(opts.path)) { + fs.mkdirSync(opts.path); + } + fs.writeFileSync(dest, cssContent); + console.log(chalk.bgGreen(chalk.white("File generated successfully."))); +}; diff --git a/packages/tokens/src/bin/Main.ts b/packages/tokens/src/bin/Main.ts new file mode 100644 index 0000000..c2e97a7 --- /dev/null +++ b/packages/tokens/src/bin/Main.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { run } from "./ThemeGenerator"; + +run(process.argv); diff --git a/packages/tokens/src/bin/Paths.ts b/packages/tokens/src/bin/Paths.ts new file mode 100644 index 0000000..c060dea --- /dev/null +++ b/packages/tokens/src/bin/Paths.ts @@ -0,0 +1,3 @@ +export const workPath = () => { + return process.cwd(); +}; diff --git a/packages/tokens/src/bin/ThemeGenerator.ts b/packages/tokens/src/bin/ThemeGenerator.ts new file mode 100644 index 0000000..36e11a0 --- /dev/null +++ b/packages/tokens/src/bin/ThemeGenerator.ts @@ -0,0 +1,39 @@ +import { program } from "commander"; +import chalk from "chalk"; +import figlet from "figlet"; +import { getConfig } from "./ConfigLoader"; +import { tokensGenerator } from "./TokensGenerator"; +import { cssGenerator } from "./CssGenerator"; +import { workPath } from "./Paths"; + +export const buildTheme = async () => { + const options = program.opts(); + const config = await getConfig(); + const tokens = tokensGenerator(config); + await cssGenerator(tokens, { + path: options.output, + selector: options.selector, + }); +}; + +export const run = async (args: string[]) => { + console.log( + chalk.red(figlet.textSync("Cunningham", { horizontalLayout: "full" })) + ); + + program + .description("Cunningham's CLI tool.") + .option( + "-o, --output ", + "Specify the output dir of generated files.", + workPath() + ) + .option( + "-s, --selector ", + "Specify the css root selector element.", + ":root" + ) + .parse(args); + + await buildTheme(); +}; diff --git a/packages/tokens/src/bin/TokensGenerator.ts b/packages/tokens/src/bin/TokensGenerator.ts new file mode 100644 index 0000000..44b44d0 --- /dev/null +++ b/packages/tokens/src/bin/TokensGenerator.ts @@ -0,0 +1,15 @@ +interface ThemeShape { + colors: { + [key: string]: string; + }; +} + +export interface ConfigShape { + theme: ThemeShape; +} + +export const tokensGenerator = (config: ConfigShape) => { + return { + colors: { ...config.theme.colors }, + }; +}; diff --git a/packages/tokens/src/bin/Utils/Flatify.ts b/packages/tokens/src/bin/Utils/Flatify.ts new file mode 100644 index 0000000..14be34a --- /dev/null +++ b/packages/tokens/src/bin/Utils/Flatify.ts @@ -0,0 +1,15 @@ +export const flatify = (obj: any, separator: string) => { + const flatObj: any = {}; + Object.keys(obj).forEach((key) => { + const value = obj[key]; + if (typeof value === "object") { + const flatChild = flatify(value, separator); + Object.keys(flatChild).forEach((subKey) => { + flatObj[key + separator + subKey] = flatChild[subKey]; + }); + } else { + flatObj[key] = value; + } + }); + return flatObj; +}; diff --git a/packages/tokens/src/bin/cunningham.dist.js b/packages/tokens/src/bin/cunningham.dist.js new file mode 100644 index 0000000..575f5ad --- /dev/null +++ b/packages/tokens/src/bin/cunningham.dist.js @@ -0,0 +1,8 @@ +module.exports = { + theme: { + colors: { + primary: "#055FD2", + secondary: "#DA0000", + }, + }, +}; diff --git a/packages/tokens/src/bin/tests/Cunningham.spec.ts b/packages/tokens/src/bin/tests/Cunningham.spec.ts new file mode 100644 index 0000000..faec68b --- /dev/null +++ b/packages/tokens/src/bin/tests/Cunningham.spec.ts @@ -0,0 +1,116 @@ +import * as fs from "fs"; +import * as path from "path"; +import { run } from "../ThemeGenerator"; +import Config from "../Config"; + +jest.mock("../Paths", () => ({ + workPath: () => __dirname, +})); + +/** + * Empty the current directory from generated tokens file and local + * config to start with an predictable environment. + */ +const cleanup = () => { + const filePath = path.join(__dirname, Config.sass.tokenFilenameCss); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + + const localConfigurationFile = path.join( + __dirname, + Config.configurationFilenames[0] + ); + if (fs.existsSync(localConfigurationFile)) { + fs.unlinkSync(localConfigurationFile); + } + + const outputPath = path.join(__dirname, "output"); + if (fs.existsSync(outputPath)) { + fs.rmSync(outputPath, { recursive: true }); + } +}; + +describe("Cunningham Bin", () => { + beforeAll(() => { + jest.spyOn(console, "log").mockImplementation(() => {}); + cleanup(); + }); + + afterEach(() => { + cleanup(); + }); + + it("Runs without existing config file with default values.", async () => { + const cssTokensFile = path.join(__dirname, Config.sass.tokenFilenameCss); + expect(fs.existsSync(cssTokensFile)).toEqual(false); + await run([]); + expect(fs.existsSync(cssTokensFile)).toEqual(true); + expect(fs.readFileSync(cssTokensFile).toString()).toEqual(`:root { +\t--c--colors--primary: #055FD2; +\t--c--colors--secondary: #DA0000; +}`); + }); + + it("Runs with existing config file using local values.", async () => { + const localConfigurationFile = path.join( + __dirname, + Config.configurationFilenames[0] + ); + expect(fs.existsSync(localConfigurationFile)).toEqual(false); + + const cssTokensFile = path.join(__dirname, Config.sass.tokenFilenameCss); + expect(fs.existsSync(cssTokensFile)).toEqual(false); + + fs.copyFileSync( + path.join(__dirname, "assets", Config.configurationFilenames[0]), + localConfigurationFile + ); + expect(fs.existsSync(localConfigurationFile)).toEqual(true); + + await run([]); + expect(fs.existsSync(cssTokensFile)).toEqual(true); + expect(fs.readFileSync(cssTokensFile).toString()).toEqual(`:root { +\t--c--colors--primary: AntiqueWhite; +\t--c--colors--secondary: #DA0000; +}`); + }); + + const testOutput = async (opt: string) => { + const outputDir = path.join(__dirname, "output"); + const cssTokensFile = path.join(outputDir, Config.sass.tokenFilenameCss); + expect(fs.existsSync(cssTokensFile)).toEqual(false); + await run(["", "", opt, outputDir]); + expect(fs.existsSync(cssTokensFile)).toEqual(true); + expect(fs.readFileSync(cssTokensFile).toString()).toEqual(`:root { +\t--c--colors--primary: #055FD2; +\t--c--colors--secondary: #DA0000; +}`); + }; + + it("Runs with -o options.", async () => { + await testOutput("-o"); + }); + + it("Runs with --output options.", async () => { + await testOutput("--output"); + }); + + const testSelector = async (opt: string) => { + const cssTokensFile = path.join(__dirname, Config.sass.tokenFilenameCss); + expect(fs.existsSync(cssTokensFile)).toEqual(false); + await run(["", "", opt, "html"]); + expect(fs.existsSync(cssTokensFile)).toEqual(true); + expect(fs.readFileSync(cssTokensFile).toString()).toEqual(`html { +\t--c--colors--primary: #055FD2; +\t--c--colors--secondary: #DA0000; +}`); + }; + + it("Runs with -s options.", async () => { + await testSelector("-s"); + }); + it("Runs with --selector options.", async () => { + await testSelector("--selector"); + }); +}); diff --git a/packages/tokens/src/bin/tests/assets/cunningham.js b/packages/tokens/src/bin/tests/assets/cunningham.js new file mode 100644 index 0000000..eb83877 --- /dev/null +++ b/packages/tokens/src/bin/tests/assets/cunningham.js @@ -0,0 +1,7 @@ +module.exports = { + theme: { + colors: { + primary: "AntiqueWhite", + }, + }, +}; diff --git a/packages/tokens/tsconfig.json b/packages/tokens/tsconfig.json new file mode 100644 index 0000000..1000b2b --- /dev/null +++ b/packages/tokens/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@openfun/typescript-configs/node.json", + "include": [ + "./src/**/*" + ], + "compilerOptions": { + "baseUrl": "./src/bin", + "outDir": "./dist/bin", + } +} \ No newline at end of file diff --git a/packages/tokens/tsconfig.node.json b/packages/tokens/tsconfig.node.json new file mode 100644 index 0000000..c4475d6 --- /dev/null +++ b/packages/tokens/tsconfig.node.json @@ -0,0 +1,3 @@ +{ + "include": ["jest.config.ts"] +}