diff --git a/src/__tests__/codegen.test.ts b/src/__tests__/codegen.test.ts new file mode 100644 index 0000000..e7e481e --- /dev/null +++ b/src/__tests__/codegen.test.ts @@ -0,0 +1,49 @@ +import type { CodegenPrepareHookArgs } from '@pandacss/types'; +import { codegen } from '../codegen'; +import { createContext } from '../context'; + +const context = createContext({ + foo: { 100: { value: { base: 'whitesmoke', lg: 'palegreen' } } }, + bar: { 100: '{colors.green.200}' }, +}); + +const args: CodegenPrepareHookArgs = { + artifacts: [ + { + id: 'css-fn', + files: [ + { file: 'css.mjs', code: '' }, + { file: 'css.d.ts', code: '' }, + ], + }, + ], + changed: [], +}; + +describe('codegen', () => { + it('generates ct runtime code', () => { + const result = codegen(args, context) as any[]; + expect(result[0].files[0]).toMatchInlineSnapshot(` + { + "code": " + const pluginCtMap = new Map(JSON.parse('[["foo.100",{"base":"whitesmoke","lg":"palegreen"}],["bar.100","{colors.green.200}"]]')); + + export const ct = (path) => { + if (!path) return 'panda-plugin-ct-path-empty'; + if (!pluginCtMap.has(path)) return 'panda-plugin-ct-alias-not-found'; + return pluginCtMap.get(path); + }; + ", + "file": "css.mjs", + } + `); + + expect(result[0].files[1]).toMatchInlineSnapshot(` + { + "code": " + export const ct: (alias: "foo.100" | "bar.100") => string;", + "file": "css.d.ts", + } + `); + }); +}); diff --git a/src/__tests__/ct.test.ts b/src/__tests__/ct.test.ts new file mode 100644 index 0000000..85fe33f --- /dev/null +++ b/src/__tests__/ct.test.ts @@ -0,0 +1,47 @@ +import { ct, ctTemplate } from '../ct'; + +const tokens = { + foo: { a: { b: { c: { value: { base: '10px', lg: '20px' } } } } }, + bar: { baz: { 100: { value: {} }, 200: { value: {} } } }, + baz: { 100: 'hello', 200: { value: 'goodbye' } }, +}; + +describe('ct', () => { + it('gets a string', () => { + expect(ct(tokens, 'baz.100')).toBe('hello'); + }); + + it('gets a value object', () => { + expect(ct(tokens, 'foo.a.b.c')).toMatchInlineSnapshot( + ` + { + "base": "10px", + "lg": "20px", + } + `, + ); + }); + + it('gets a value string', () => { + expect(ct(tokens, 'baz.200')).toMatchInlineSnapshot(`"goodbye"`); + }); + + it('gets an undefined token', () => { + expect(ct(tokens, 'nope.nope')).toBeUndefined(); + expect(ct(tokens, 'foo.baz')).toBeUndefined(); + }); +}); + +describe('getTemplate', () => { + it('generates a ct function', () => { + expect(ctTemplate).toMatchInlineSnapshot(` + " + export const ct = (path) => { + if (!path) return 'panda-plugin-ct-path-empty'; + if (!pluginCtMap.has(path)) return 'panda-plugin-ct-alias-not-found'; + return pluginCtMap.get(path); + }; + " + `); + }); +}); diff --git a/src/__tests__/get.test.ts b/src/__tests__/get.test.ts deleted file mode 100644 index 9fb3a7b..0000000 --- a/src/__tests__/get.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { get, getTemplate } from '../get'; - -const tokens = { - foo: { a: { b: { c: { value: { base: '10px', lg: '20px' } } } } }, - bar: { baz: { 100: { value: {} }, 200: { value: {} } } }, - baz: { 100: 'hello', 200: { value: 'goodbye' } }, -}; - -describe('get', () => { - it('gets a string', () => { - expect(get(tokens)('baz.100')).toBe('"hello"'); - }); - - it('gets a value object', () => { - expect(get(tokens)('foo.a.b.c')).toMatchInlineSnapshot( - `"{"base":"10px","lg":"20px"}"`, - ); - }); - - it('gets a value string', () => { - expect(get(tokens)('baz.200')).toMatchInlineSnapshot(`""goodbye""`); - }); - - it('gets an undefined token', () => { - expect(get(tokens)('nope.nope')).toBe(`"panda-plugin-ct-alias-not-found"`); - }); - - it('gets an undefined path', () => { - // @ts-expect-error Checking arg omission - expect(get(tokens)()).toBe(`"panda-plugin-ct-alias-not-found"`); - }); -}); - -describe('getTemplate', () => { - it('generates a ct function', () => { - expect(getTemplate({ foo: { value: { base: '#fff', md: '#000' } } })) - .toMatchInlineSnapshot(` - " - const pluginCtTokens = { - "foo": { - "value": { - "base": "#fff", - "md": "#000" - } - } - }; - - export const ct = (path) => { - if (!path) return "panda-plugin-ct-alias-not-found"; - - const parts = path.split('.'); - let current = pluginCtTokens; - - for (const part of parts) { - if (!current[part]) break; - current = current[part]; - } - - if (typeof current === 'string') { - return current; - } - - if (typeof current === 'object' && current != null && !Array.isArray(current) && 'value' in current) { - return current.value; - } - - return "panda-plugin-ct-alias-not-found"; - }; - " - `); - }); -}); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts new file mode 100644 index 0000000..86babfd --- /dev/null +++ b/src/__tests__/index.test.ts @@ -0,0 +1,9 @@ +import { pluginComponentTokens } from '..'; + +describe('pluginComponentTokens', () => { + it('returns a PandaPlugin', () => { + expect(pluginComponentTokens).toBeTypeOf('function'); + expect(pluginComponentTokens({}).name).toBeDefined(); + expect(pluginComponentTokens({}).hooks).toBeDefined(); + }); +}); diff --git a/src/__tests__/map.test.ts b/src/__tests__/map.test.ts new file mode 100644 index 0000000..1d8b897 --- /dev/null +++ b/src/__tests__/map.test.ts @@ -0,0 +1,51 @@ +import { makeMap, makePaths, mapTemplate } from '../map'; + +const tokens = { + foo: { 100: { value: '#fff' }, 200: { value: { base: '#000' } } }, + bar: { 100: 'red', 200: 'blue' }, +}; + +describe('makePaths', () => { + it('makes paths', () => { + expect(makePaths(tokens)).toMatchInlineSnapshot(` + [ + "foo.100", + "foo.200", + "bar.100", + "bar.200", + ] + `); + }); +}); + +describe('mapTemplate', () => { + it('serializes a Map', () => { + const map = new Map([ + ['foo.100', '#fff'], + ['foo.200', { base: '#000' }], + ]); + + expect(mapTemplate(map)).toMatchInlineSnapshot( + ` + " + const pluginCtMap = new Map(JSON.parse('[["foo.100","#fff"],["foo.200",{"base":"#000"}]]')); + " + `, + ); + }); +}); + +describe('makeMap', () => { + it('makes a map', () => { + expect(makeMap(tokens)).toMatchInlineSnapshot(` + Map { + "foo.100" => "#fff", + "foo.200" => { + "base": "#000", + }, + "bar.100" => "red", + "bar.200" => "blue", + } + `); + }); +}); diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts new file mode 100644 index 0000000..ba67c22 --- /dev/null +++ b/src/__tests__/parser.test.ts @@ -0,0 +1,50 @@ +import { parser } from '../parser'; +import { createContext } from '../context'; + +const context = createContext({ + foo: { 100: { value: { base: 'whitesmoke', lg: 'palegreen' } } }, + bar: { 100: '{colors.green.200}' }, +}); + +describe('parser', () => { + it('parses', () => { + const res = parser( + { + configure: () => {}, + filePath: 'test.tsx', + content: `
`, + }, + context, + ); + + expect(res).toMatchInlineSnapshot( + `"
"`, + ); + }); + + it('skips without "ct(" in contents', () => { + const res = parser( + { + configure: () => {}, + filePath: 'test.tsx', + content: `
`, + }, + context, + ); + + expect(res).toBeUndefined(); + }); + + it('skips without a path', () => { + const res = parser( + { + configure: () => {}, + filePath: 'test.tsx', + content: `
`, + }, + context, + ); + + expect(res).toMatchInlineSnapshot(`"
"`); + }); +}); diff --git a/src/__tests__/tsconfig.json b/src/__tests__/tsconfig.json deleted file mode 100644 index 4a57ad7..0000000 --- a/src/__tests__/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "compilerOptions": { - "types": ["vitest/globals"] - }, - "include": ["./**/*.ts"] -} diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 7f7599c..d51ba7e 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,49 +1,24 @@ -import { isObject, makePaths } from '../utils'; +import { isObjectWithValue, isObject } from '../utils'; -describe('isObject', () => { - it('returns true if an object', () => { - expect(isObject({})).toBe(true); - expect(isObject({ foo: 'bar' })).toBe(true); +describe('isObjectWithValue', () => { + it('returns true for an object with a value property', () => { + expect(isObjectWithValue({ value: '' })).toBe(true); + expect(isObjectWithValue({ value: {} })).toBe(true); }); - it('returns false if not an object', () => { - expect(isObject(1)).toBe(false); - expect(isObject('1')).toBe(false); - expect(isObject(undefined)).toBe(false); - expect(isObject(null)).toBe(false); - expect(isObject([1, 2, 3])).toBe(false); + it('returns false for an object without a value property', () => { + expect(isObjectWithValue({})).toBe(false); }); }); -describe('makePaths', () => { - it('makes paths', () => { - expect( - makePaths({ - foo: { 100: { value: '#fff' }, 200: '' }, - bar: { 100: '', 200: '' }, - }), - ).toMatchInlineSnapshot(` - [ - "foo.100", - "foo.200", - "bar.100", - "bar.200", - ] - `); +describe('isObject', () => { + it('returns true for an object', () => { + expect(isObject({})).toBe(true); }); - it('makes paths with object values', () => { - expect( - makePaths({ - foo: { a: { b: { c: { value: { base: '', lg: '' } } } } }, - bar: { baz: { 100: { value: '#fff' }, 200: { value: {} } } }, - }), - ).toMatchInlineSnapshot(` - [ - "foo.a.b.c", - "bar.baz.100", - "bar.baz.200", - ] - `); + it('returns false for not an object', () => { + expect(isObject([1, 2, 3])).toBe(false); + expect(isObject(null)).toBe(false); + expect(isObject(undefined)).toBe(false); }); }); diff --git a/src/codegen.ts b/src/codegen.ts index 90113bb..e6e02e4 100644 --- a/src/codegen.ts +++ b/src/codegen.ts @@ -3,16 +3,15 @@ import type { MaybeAsyncReturn, Artifact, } from '@pandacss/types'; -import { makePaths } from './utils'; +import { makePaths, mapTemplate } from './map'; import type { PluginContext } from './types'; -import { getTemplate } from './get'; +import { ctTemplate } from './ct'; export const codegen = ( args: CodegenPrepareHookArgs, - context: Partial, + context: PluginContext, ): MaybeAsyncReturn => { - const tokens = context.tokens ?? {}; - if (!tokens) return; + const { tokens, map } = context; const cssFn = args.artifacts.find((a) => a.id === 'css-fn'); if (!cssFn) return args.artifacts; @@ -20,7 +19,8 @@ export const codegen = ( const cssFile = cssFn.files.find((f) => f.file.includes('css.mjs')); if (!cssFile) return args.artifacts; - cssFile.code += getTemplate(tokens); + cssFile.code += mapTemplate(map); + cssFile.code += ctTemplate; const cssDtsFile = cssFn.files.find((f) => f.file.includes('css.d.')); if (!cssDtsFile) return args.artifacts; diff --git a/src/create-project.ts b/src/context.ts similarity index 66% rename from src/create-project.ts rename to src/context.ts index befb61f..a50f000 100644 --- a/src/create-project.ts +++ b/src/context.ts @@ -1,7 +1,9 @@ import { Project, ts } from 'ts-morph'; +import type { ComponentTokens, PluginContext } from './types'; +import { makeMap } from './map'; -export const createProject = () => { - return new Project({ +export const createContext = (tokens: ComponentTokens): PluginContext => ({ + project: new Project({ compilerOptions: { jsx: ts.JsxEmit.React, jsxFactory: 'React.createElement', @@ -16,5 +18,7 @@ export const createProject = () => { skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: true, skipLoadingLibFiles: true, - }); -}; + }), + tokens, + map: makeMap(tokens), +}); diff --git a/src/ct.ts b/src/ct.ts new file mode 100644 index 0000000..aea95f0 --- /dev/null +++ b/src/ct.ts @@ -0,0 +1,25 @@ +import type { ComponentTokens } from './types'; +import { isObjectWithValue } from './utils'; + +export const ct = (tokens: ComponentTokens, path: T) => { + const parts = path.split('.'); + let current = tokens; + + for (const part of parts) { + if (!current[part]) break; + current = current[part] as ComponentTokens; + } + + if (typeof current === 'string') return current; + if (isObjectWithValue(current)) return current.value; + + return; +}; + +export const ctTemplate = ` +export const ct = (path) => { + if (!path) return 'panda-plugin-ct-path-empty'; + if (!pluginCtMap.has(path)) return 'panda-plugin-ct-alias-not-found'; + return pluginCtMap.get(path); +}; +`; diff --git a/src/get.ts b/src/get.ts deleted file mode 100644 index fe2fe0d..0000000 --- a/src/get.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { ComponentTokens } from './types'; -import { isObject } from './utils'; - -const missing = `"panda-plugin-ct-alias-not-found"`; - -export const get = - (tokens: ComponentTokens) => - (path: string): string => { - if (!path) return missing; - - const parts = path.split('.'); - let current = tokens; - - for (const part of parts) { - if (!current[part]) break; - current = current[part] as ComponentTokens; - } - - if (typeof current === 'string') { - return `"${current}"`; - } - - if (isObject(current) && 'value' in current) { - return typeof current.value === 'string' - ? `"${current.value}"` - : JSON.stringify(current.value); - } - - return missing; - }; - -export const getTemplate = (tokens: ComponentTokens) => ` -const pluginCtTokens = ${JSON.stringify(tokens, null, 2)}; - -export const ct = (path) => { - if (!path) return ${missing}; - - const parts = path.split('.'); - let current = pluginCtTokens; - - for (const part of parts) { - if (!current[part]) break; - current = current[part]; - } - - if (typeof current === 'string') { - return current; - } - - if (typeof current === 'object' && current != null && !Array.isArray(current) && 'value' in current) { - return current.value; - } - - return ${missing}; -}; -`; diff --git a/src/index.ts b/src/index.ts index 838f449..44db809 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,25 +1,23 @@ import type { PandaPlugin } from '@pandacss/types'; import { parser } from './parser'; import { codegen } from './codegen'; -import { createProject } from './create-project'; -import type { ComponentTokens, PluginContext } from './types'; +import { createContext } from './context'; +import type { ComponentTokens } from './types'; +import { makeMap } from './map'; /** - * + * @see https://github.com/jonambas/panda-plugin-ct */ const pluginComponentTokens = (tokens: ComponentTokens): PandaPlugin => { - const context: Partial = {}; + const context = createContext(tokens); return { name: 'panda-plugin-ct', hooks: { - 'config:resolved': () => { - context.project = createProject(); - context.tokens = tokens; - }, 'parser:before': (args) => { return parser(args, context); }, 'codegen:prepare': (args) => { + context.map = makeMap(tokens); return codegen(args, context); }, }, diff --git a/src/map.ts b/src/map.ts new file mode 100644 index 0000000..92fc92f --- /dev/null +++ b/src/map.ts @@ -0,0 +1,45 @@ +import { ct } from './ct'; +import type { ComponentTokens } from './types'; +import { isObject } from './utils'; + +// Create an array of all string paths from an object. +export const makePaths = ( + obj: Record, + prefix?: string, +): string[] => { + const pathPrefix = prefix ? prefix + '.' : ''; + const paths = []; + + for (const [key, value] of Object.entries(obj)) { + if (!isObject(value) || 'value' in value) { + paths.push(`${pathPrefix}${key}`); + } else { + paths.push(...makePaths(value, `${pathPrefix}${key}`)); + } + } + + return paths; +}; + +// Create a Map of all alias paths and values. +export const makeMap = (tokens: ComponentTokens) => { + const map = new Map(); + + for (const path of makePaths(tokens)) { + const value = ct(tokens, path); + if (value) { + map.set(path, value); + } + } + + return map; +}; + +// Serialize a Map to a JSON string. +const serializeMap = (map: Map) => { + return JSON.stringify(Array.from(map.entries())); +}; + +// Generate a template string for the token alias Map. +export const mapTemplate = (map: Map) => + `\nconst pluginCtMap = new Map(JSON.parse('${serializeMap(map)}'));\n`; diff --git a/src/parser.ts b/src/parser.ts index ae9df4c..a04e283 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,15 +1,12 @@ import type { ParserResultBeforeHookArgs } from '@pandacss/types'; import type { PluginContext } from './types'; -import { get } from './get'; +import { isObject } from './utils'; export const parser = ( args: ParserResultBeforeHookArgs, - context: Partial, + context: PluginContext, ): string | void => { - const tokens = context.tokens ?? {}; - const project = context.project; - - if (!tokens || !project) return; + const { project, map } = context; // TODO: handle `import { ct as xyz }` aliasing const content = args.content; @@ -19,10 +16,8 @@ export const parser = ( overwrite: true, }); - const text = source.getText(); + let text = source.getText(); const calls = text.match(/ct\(['"][\w.]+['"]\)/g) ?? []; - const ct = get(tokens); - let newText = text; for (const call of calls) { const path = call @@ -30,8 +25,12 @@ export const parser = ( ?.toString() .replace(/['"]/g, ''); if (!path) continue; - newText = newText.replace(call, ct(path)); + const value = map.get(path); + text = text.replace( + call, + isObject(value) ? JSON.stringify(value) : `'${value}'`, + ); } - return newText; + return text; }; diff --git a/src/types.ts b/src/types.ts index f620cfa..f8e7238 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,4 +5,5 @@ export type ComponentTokens = { [k: string]: string | ComponentTokens }; export type PluginContext = { project: Project; tokens: ComponentTokens; + map: Map; }; diff --git a/src/utils.ts b/src/utils.ts index 650c222..2aa9fa6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,20 +2,11 @@ export const isObject = (value: any) => { return typeof value === 'object' && value != null && !Array.isArray(value); }; -export const makePaths = ( - obj: Record, - prefix?: string, -): string[] => { - const pathPrefix = prefix ? prefix + '.' : ''; - const paths = []; - - for (const [key, value] of Object.entries(obj)) { - if (!isObject(value) || 'value' in value) { - paths.push(`${pathPrefix}${key}`); - } else { - paths.push(...makePaths(value, `${pathPrefix}${key}`)); - } - } - - return paths; +export const isObjectWithValue = (obj: any) => { + return ( + typeof obj === 'object' && + obj != null && + !Array.isArray(obj) && + 'value' in obj + ); }; diff --git a/tsconfig.json b/tsconfig.json index 309ba52..0a4bd51 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,8 +6,9 @@ "outDir": "dist", "strict": true, "esModuleInterop": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": ["vitest/globals"] }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "**/__tests__/**"] + "exclude": ["node_modules"] }