diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c2b078f4ac..6927354cb65e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support customizing class name when using `darkMode: 'class'` ([#5800](https://github.com/tailwindlabs/tailwindcss/pull/5800)) - Add `--poll` option to the CLI ([#7725](https://github.com/tailwindlabs/tailwindcss/pull/7725)) - Add new `border-spacing` utilities ([#7102](https://github.com/tailwindlabs/tailwindcss/pull/7102)) +- Add TypeScript types for the `tailwind.config.js` file ([#7891](https://github.com/tailwindlabs/tailwindcss/pull/7891)) ## [3.0.23] - 2022-02-16 diff --git a/colors.d.ts b/colors.d.ts new file mode 100644 index 000000000000..d85ab869a0b7 --- /dev/null +++ b/colors.d.ts @@ -0,0 +1,3 @@ +import type { DefaultColors } from './types/generated/colors' +declare const colors: DefaultColors +export = colors diff --git a/defaultConfig.d.ts b/defaultConfig.d.ts new file mode 100644 index 000000000000..2c2bccf2c4cc --- /dev/null +++ b/defaultConfig.d.ts @@ -0,0 +1,3 @@ +import type { Config } from './types/config' +declare const config: Config +export = config diff --git a/defaultTheme.d.ts b/defaultTheme.d.ts new file mode 100644 index 000000000000..9172051233cd --- /dev/null +++ b/defaultTheme.d.ts @@ -0,0 +1,3 @@ +import type { Config } from './types/config' +declare const theme: Config['theme'] +export = theme diff --git a/integrations/tailwindcss-cli/tests/cli.test.js b/integrations/tailwindcss-cli/tests/cli.test.js index 644ebf2267cd..8289e65d027d 100644 --- a/integrations/tailwindcss-cli/tests/cli.test.js +++ b/integrations/tailwindcss-cli/tests/cli.test.js @@ -319,6 +319,46 @@ describe('Init command', () => { // multiple keys in `theme` exists. However it loads `tailwindcss/colors` // which doesn't exists in this context. expect((await readOutputFile('../full.config.js')).split('\n').length).toBeGreaterThan(50) + + expect(await readOutputFile('../full.config.js')).not.toContain( + `/** @type {import('tailwindcss/types').Config} */` + ) + }) + + test('--types', async () => { + cleanupFile('simple.config.js') + + let { combined } = await $(`${EXECUTABLE} init simple.config.js --types`) + + expect(combined).toMatchInlineSnapshot(` + " + Created Tailwind CSS config file: simple.config.js + " + `) + + expect(await readOutputFile('../simple.config.js')).toContain( + `/** @type {import('tailwindcss/types').Config} */` + ) + }) + + test('--full --types', async () => { + cleanupFile('full.config.js') + + let { combined } = await $(`${EXECUTABLE} init full.config.js --full --types`) + + expect(combined).toMatchInlineSnapshot(` + " + Created Tailwind CSS config file: full.config.js + " + `) + + // Not a clean way to test this. We could require the file and verify that + // multiple keys in `theme` exists. However it loads `tailwindcss/colors` + // which doesn't exists in this context. + expect((await readOutputFile('../full.config.js')).split('\n').length).toBeGreaterThan(50) + expect(await readOutputFile('../full.config.js')).toContain( + `/** @type {import('tailwindcss/types').Config} */` + ) }) test('--postcss', async () => { @@ -351,6 +391,7 @@ describe('Init command', () => { Options: -f, --full Initialize a full \`tailwind.config.js\` file -p, --postcss Initialize a \`postcss.config.js\` file + --types Add TypeScript types for the \`tailwind.config.js\` file -h, --help Display usage information `) ) diff --git a/package.json b/package.json index c455ac92cdf7..cf9ce554a8bf 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "A utility-first CSS framework for rapidly building custom user interfaces.", "license": "MIT", "main": "lib/index.js", - "style": "dist/tailwind.css", + "types": "types/index.d.ts", "repository": "https://github.com/tailwindlabs/tailwindcss.git", "bugs": "https://github.com/tailwindlabs/tailwindcss/issues", "homepage": "https://tailwindcss.com", @@ -13,18 +13,20 @@ "tailwindcss": "lib/cli.js" }, "scripts": { - "preswcify": "npm run generate:plugin-list && rimraf lib", + "preswcify": "npm run generate && rimraf lib", "swcify": "swc src --out-dir lib --copy-files", "postswcify": "esbuild lib/cli-peer-dependencies.js --bundle --platform=node --outfile=peers/index.js", "rebuild-fixtures": "npm run swcify && node -r @swc/register scripts/rebuildFixtures.js", "prepublishOnly": "npm install --force && npm run swcify", "style": "eslint .", - "pretest": "npm run generate:plugin-list", + "pretest": "npm run generate", "test": "jest", "test:integrations": "npm run test --prefix ./integrations", "install:integrations": "node scripts/install-integrations.js", "posttest": "npm run style", - "generate:plugin-list": "node -r @swc/register scripts/create-plugin-list.js" + "generate:plugin-list": "node -r @swc/register scripts/create-plugin-list.js", + "generate:types": "node -r @swc/register scripts/generate-types.js", + "generate": "npm run generate:plugin-list && npm run generate:types" }, "files": [ "src/*", @@ -34,6 +36,8 @@ "scripts/*.js", "stubs/*.stub.js", "nesting/*", + "types/**/*", + "*.d.ts", "*.css", "*.js" ], diff --git a/plugin.d.ts b/plugin.d.ts new file mode 100644 index 000000000000..f78aad764505 --- /dev/null +++ b/plugin.d.ts @@ -0,0 +1,6 @@ +import type { Config, PluginCreator } from './types/config' +declare function createPlugin( + plugin: PluginCreator, + config?: Config +): { handler: PluginCreator; config?: Config } +export = createPlugin diff --git a/scripts/generate-types.js b/scripts/generate-types.js new file mode 100644 index 000000000000..ef45233360f6 --- /dev/null +++ b/scripts/generate-types.js @@ -0,0 +1,52 @@ +import prettier from 'prettier' +import { corePlugins } from '../src/corePlugins' +import colors from '../src/public/colors' +import fs from 'fs' +import path from 'path' + +fs.writeFileSync( + path.join(process.cwd(), 'types', 'generated', 'corePluginList.d.ts'), + `export type CorePluginList = ${Object.keys(corePlugins) + .map((p) => `'${p}'`) + .join(' | ')}` +) + +let colorsWithoutDeprecatedColors = Object.fromEntries( + Object.entries(Object.getOwnPropertyDescriptors(colors)) + .filter(([_, { value }]) => { + return typeof value !== 'undefined' + }) + .map(([name, definition]) => [name, definition.value]) +) + +let deprecatedColors = Object.entries(Object.getOwnPropertyDescriptors(colors)) + .filter(([_, { value }]) => { + return typeof value === 'undefined' + }) + .map(([name, definition]) => { + let warn = console.warn + let messages = [] + console.warn = (...args) => messages.push(args.pop()) + definition.get() + console.warn = warn + let message = messages.join(' ').trim() + let newColor = message.match(/renamed to `(.*)`/)[1] + return `/** @deprecated ${message} */${name}: DefaultColors['${newColor}'],` + }) + .join('\n') + +fs.writeFileSync( + path.join(process.cwd(), 'types', 'generated', 'colors.d.ts'), + prettier.format( + `export interface DefaultColors { ${JSON.stringify(colorsWithoutDeprecatedColors).slice( + 1, + -1 + )}\n${deprecatedColors}\n}`, + { + semi: false, + singleQuote: true, + printWidth: 100, + parser: 'typescript', + } + ) +) diff --git a/src/cli.js b/src/cli.js index 8aee860a870f..5770546e89a8 100644 --- a/src/cli.js +++ b/src/cli.js @@ -151,6 +151,10 @@ let commands = { args: { '--full': { type: Boolean, description: 'Initialize a full `tailwind.config.js` file' }, '--postcss': { type: Boolean, description: 'Initialize a `postcss.config.js` file' }, + '--types': { + type: Boolean, + description: 'Add TypeScript types for the `tailwind.config.js` file', + }, '-f': '--full', '-p': '--postcss', }, @@ -209,7 +213,7 @@ if ( help({ usage: [ 'tailwindcss [--input input.css] [--output output.css] [--watch] [options...]', - 'tailwindcss init [--full] [--postcss] [options...]', + 'tailwindcss init [--full] [--postcss] [--types] [options...]', ], commands: Object.keys(commands) .filter((command) => command !== 'build') @@ -336,6 +340,13 @@ function init() { 'utf8' ) + if (args['--types']) { + let typesHeading = "/** @type {import('tailwindcss/types').Config} */" + stubFile = + stubFile.replace(`module.exports = `, `${typesHeading}\nconst config = `) + + '\nmodule.exports = config' + } + // Change colors import stubFile = stubFile.replace('../colors', 'tailwindcss/colors') diff --git a/types.d.ts b/types.d.ts new file mode 100644 index 000000000000..2ef03a931bd5 --- /dev/null +++ b/types.d.ts @@ -0,0 +1 @@ +export type { Config } from './types/config' diff --git a/types/config.d.ts b/types/config.d.ts new file mode 100644 index 000000000000..9b706bf99416 --- /dev/null +++ b/types/config.d.ts @@ -0,0 +1,322 @@ +import type { CorePluginList } from './generated/CorePluginList' +import type { DefaultColors } from './generated/colors' + +// Helpers +type Expand = T extends object + ? T extends infer O + ? { [K in keyof O]: Expand } + : never + : T +type KeyValuePair = Record +interface RecursiveKeyValuePair { + [key: string]: V | RecursiveKeyValuePair +} +type ResolvableTo = T | ((utils: PluginUtils) => T) + +interface PluginUtils { + colors: DefaultColors + theme(path: string, defaultValue: unknown): keyof ThemeConfig + breakpoints, O = I>(arg: I): O + rgb(arg: string): (arg: Partial<{ opacityVariable: string; opacityValue: number }>) => string + hsl(arg: string): (arg: Partial<{ opacityVariable: string; opacityValue: number }>) => string +} + +// Content related config +type FilePath = string +type RawFile = { raw: string; extension?: string } +type ExtractorFn = (content: string) => string[] +type TransformerFn = (content: string) => string +type ContentConfig = + | (FilePath | RawFile)[] + | { + files: (FilePath | RawFile)[] + extract?: ExtractorFn | { [extension: string]: ExtractorFn } + transform?: TransformerFn | { [extension: string]: TransformerFn } + } + +// Important related config +type ImportantConfig = boolean | string + +// Prefix related config +type PrefixConfig = string + +// Separator related config +type SeparatorConfig = string + +// Safelist related config +type SafelistConfig = + | string[] + | { + pattern: RegExp + variants: string[] + }[] + +// Presets related config +type PresetsConfig = Config[] + +// Future related config +type FutureConfigValues = never // Replace with 'future-feature-1' | 'future-feature-2' +type FutureConfig = Expand<'all' | Partial>> | [] + +// Experimental related config +type ExperimentalConfigValues = 'optimizeUniversalDefaults' // Replace with 'experimental-feature-1' | 'experimental-feature-2' +type ExperimentalConfig = Expand<'all' | Partial>> | [] + +// DarkMode related config +type DarkModeConfig = + /** Use the `media` query strategy. */ + | 'media' + /** Use the `class` stategy, which requires a `.dark` class on the `html`. */ + | 'class' + /** Use the `class` stategy with a custom class instead of `.dark`. */ + | ['class', string] + +// Theme related config +interface ThemeConfig { + extend: Partial> + + /** Responsiveness */ + screens: ResolvableTo + + /** Reusable base configs */ + colors: ResolvableTo + spacing: ResolvableTo + + /** Components */ + container: ResolvableTo< + Partial<{ + screens: + | string[] /** List of breakpoints. E.g.: '400px', '500px' */ + /** Named breakpoints. E.g.: { sm: '400px' } */ + | Record + /** Name breakpoints with explicit min and max values. E.g.: { sm: { min: '300px', max: '400px' } } */ + | Record + center: boolean + padding: string | Record + }> + > + + /** Utilities */ + inset: ThemeConfig['spacing'] + zIndex: ResolvableTo + order: ResolvableTo + gridColumn: ResolvableTo + gridColumnStart: ResolvableTo + gridColumnEnd: ResolvableTo + gridRow: ResolvableTo + gridRowStart: ResolvableTo + gridRowEnd: ResolvableTo + margin: ThemeConfig['spacing'] + aspectRatio: ResolvableTo + height: ThemeConfig['spacing'] + maxHeight: ThemeConfig['spacing'] + minHeight: ResolvableTo + width: ThemeConfig['spacing'] + maxWidth: ResolvableTo + minWidth: ResolvableTo + flex: ResolvableTo + flexShrink: ResolvableTo + flexGrow: ResolvableTo + flexBasis: ThemeConfig['spacing'] + borderSpacing: ThemeConfig['spacing'] + transformOrigin: ResolvableTo + translate: ThemeConfig['spacing'] + rotate: ResolvableTo + skew: ResolvableTo + scale: ResolvableTo + animation: ResolvableTo + keyframes: ResolvableTo>> + cursor: ResolvableTo + scrollMargin: ThemeConfig['spacing'] + scrollPadding: ThemeConfig['spacing'] + listStyleType: ResolvableTo + columns: ResolvableTo + gridAutoColumns: ResolvableTo + gridAutoRows: ResolvableTo + gridTemplateColumns: ResolvableTo + gridTemplateRows: ResolvableTo + gap: ThemeConfig['spacing'] + space: ThemeConfig['spacing'] + divideWidth: ThemeConfig['borderWidth'] + divideColor: ThemeConfig['borderColor'] + divideOpacity: ThemeConfig['borderOpacity'] + borderRadius: ResolvableTo + borderWidth: ResolvableTo + borderColor: ThemeConfig['colors'] + borderOpacity: ThemeConfig['opacity'] + backgroundColor: ThemeConfig['colors'] + backgroundOpacity: ThemeConfig['opacity'] + backgroundImage: ResolvableTo + gradientColorStops: ThemeConfig['colors'] + backgroundSize: ResolvableTo + backgroundPosition: ResolvableTo + fill: ThemeConfig['colors'] + stroke: ThemeConfig['colors'] + strokeWidth: ResolvableTo + objectPosition: ResolvableTo + padding: ThemeConfig['spacing'] + textIndent: ThemeConfig['spacing'] + fontFamily: ResolvableTo> + fontSize: ResolvableTo< + | KeyValuePair + | KeyValuePair + | KeyValuePair< + string, + [ + fontSize: string, + configuration: Partial<{ + lineHeight: string + letterSpacing: string + }> + ] + > + > + fontWeight: ResolvableTo + lineHeight: ResolvableTo + letterSpacing: ResolvableTo + textColor: ThemeConfig['colors'] + textOpacity: ThemeConfig['opacity'] + textDecorationColor: ThemeConfig['colors'] + textDecorationThickness: ResolvableTo + textUnderlineOffset: ResolvableTo + placeholderColor: ThemeConfig['colors'] + placeholderOpacity: ThemeConfig['opacity'] + caretColor: ThemeConfig['colors'] + accentColor: ThemeConfig['colors'] + opacity: ResolvableTo + boxShadow: ResolvableTo + boxShadowColor: ThemeConfig['colors'] + outlineWidth: ResolvableTo + outlineOffset: ResolvableTo + outlineColor: ThemeConfig['colors'] + ringWidth: ResolvableTo + ringColor: ThemeConfig['colors'] + ringOpacity: ThemeConfig['opacity'] + ringOffsetWidth: ResolvableTo + ringOffsetColor: ThemeConfig['colors'] + blur: ResolvableTo + brightness: ResolvableTo + contrast: ResolvableTo + dropShadow: ResolvableTo + grayscale: ResolvableTo + hueRotate: ResolvableTo + invert: ResolvableTo + saturate: ResolvableTo + sepia: ResolvableTo + backdropBlur: ThemeConfig['blur'] + backdropBrightness: ThemeConfig['brightness'] + backdropContrast: ThemeConfig['contrast'] + backdropGrayscale: ThemeConfig['grayscale'] + backdropHueRotate: ThemeConfig['hueRotate'] + backdropInvert: ThemeConfig['invert'] + backdropOpacity: ThemeConfig['opacity'] + backdropSaturate: ThemeConfig['saturate'] + backdropSepia: ThemeConfig['sepia'] + transitionProperty: ResolvableTo + transitionTimingFunction: ResolvableTo + transitionDelay: ResolvableTo + transitionDuration: ResolvableTo + willChange: ResolvableTo + content: ResolvableTo +} + +// Core plugins related config +type CorePluginsConfig = CorePluginList[] | Expand>> + +// Plugins related config +type ValueType = + | 'any' + | 'color' + | 'url' + | 'image' + | 'length' + | 'percentage' + | 'position' + | 'lookup' + | 'generic-name' + | 'family-name' + | 'number' + | 'line-width' + | 'absolute-size' + | 'relative-size' + | 'shadow' +export interface PluginAPI { + /** for registering new static utility styles */ + addUtilities( + utilities: RecursiveKeyValuePair | RecursiveKeyValuePair[], + options?: Partial<{ + respectPrefix: boolean + respectImportant: boolean + }> + ): void + /** for registering new dynamic utility styles */ + matchUtilities( + utilities: KeyValuePair RecursiveKeyValuePair>, + options?: Partial<{ + respectPrefix: boolean + respectImportant: boolean + type: ValueType | ValueType[] + values: KeyValuePair + supportsNegativeValues: boolean + }> + ): void + /** for registering new static component styles */ + addComponents( + components: RecursiveKeyValuePair | RecursiveKeyValuePair[], + options?: Partial<{ + respectPrefix: boolean + respectImportant: boolean + }> + ): void + /** for registering new dynamic component styles */ + matchComponents( + components: KeyValuePair RecursiveKeyValuePair>, + options?: Partial<{ + respectPrefix: boolean + respectImportant: boolean + type: ValueType | ValueType[] + values: KeyValuePair + supportsNegativeValues: boolean + }> + ): void + /** for registering new base styles */ + addBase(base: RecursiveKeyValuePair | RecursiveKeyValuePair[]): void + /** for registering custom variants */ + addVariant(name: string, definition: string | string[] | (() => string) | (() => string)[]): void + /** for looking up values in the user’s theme configuration */ + theme: ( + path?: string, + defaultValue?: TDefaultValue + ) => TDefaultValue + /** for looking up values in the user’s Tailwind configuration */ + config: (path?: string, defaultValue?: TDefaultValue) => TDefaultValue + /** for checking if a core plugin is enabled */ + corePlugins(path: string): boolean + /** for manually escaping strings meant to be used in class names */ + e: (className: string) => string +} +export type PluginCreator = (api: PluginAPI) => void +export type PluginsConfig = (PluginCreator | { handler: PluginCreator; config?: Config })[] + +// Top level config related +interface RequiredConfig { + content: ContentConfig +} + +interface OptionalConfig { + important: ImportantConfig + prefix: PrefixConfig + separator: SeparatorConfig + safelist: SafelistConfig + presets: PresetsConfig + future: FutureConfig + experimental: ExperimentalConfig + darkMode: DarkModeConfig + theme: ThemeConfig + corePlugins: CorePluginsConfig + plugins: PluginsConfig + /** Custom */ + [key: string]: any +} + +export type Config = RequiredConfig & Partial diff --git a/types/generated/.gitignore b/types/generated/.gitignore new file mode 100644 index 000000000000..d6b7ef32c847 --- /dev/null +++ b/types/generated/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 000000000000..21051bea0236 --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1 @@ +declare namespace tailwindcss {}