Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow JS plugin to nest token output #79

Merged
merged 4 commits into from
Aug 18, 2023
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
6 changes: 6 additions & 0 deletions .changeset/small-birds-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@cobalt-ui/plugin-js': minor
'@cobalt-ui/utils': minor
---

Allow JS plugin output to be nested
108 changes: 80 additions & 28 deletions packages/plugin-js/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {BuildResult, Mode, ParsedToken, Plugin, Token} from '@cobalt-ui/core';
import {cloneDeep, indent, objKey} from '@cobalt-ui/utils';
import {cloneDeep, indent, objKey, set} from '@cobalt-ui/utils';

const JS_EXT_RE = /\.(mjs|js)$/i;
const JSON_EXT_RE = /\.json$/i;
Expand All @@ -16,18 +16,20 @@ export interface Options {
meta?: boolean;
/** modify values */
transform?: TransformFn;
/** nested or flat output (default: false) */
deep?: boolean;
}

interface JSResult {
tokens: {[id: string]: ParsedToken['$value']};
meta?: {[id: string]: Token};
modes: {[id: string]: Mode<ParsedToken['$value']>};
tokens: {[id: string]: ParsedToken['$value'] | JSResult['tokens']};
meta?: {[id: string]: Token | JSResult['meta']};
modes: {[id: string]: Mode<ParsedToken['$value'] | JSResult['modes']>};
}

interface TSResult {
tokens: string[];
meta?: string[];
modes: string[];
tokens: {[id: string]: string | TSResult['tokens']};
meta?: {[id: string]: string | TSResult['meta']};
modes: {[id: string]: string | TSResult['modes']};
}

const tokenTypes: Record<ParsedToken['$type'], string> = {
Expand All @@ -48,8 +50,9 @@ const tokenTypes: Record<ParsedToken['$type'], string> = {
};

/** serialize JS ref into string */
export function serializeJS(value: unknown, options?: {comments?: Record<string, string>; indentLv?: number}): string {
export function serializeJS(value: unknown, options?: {comments?: Record<string, string>; commentPath?: string; indentLv?: number}): string {
const comments = options?.comments || {};
const commentPath = options?.commentPath ?? '';
const indentLv = options?.indentLv || 0;
if (typeof value === 'string') return `'${value.replace(SINGLE_QUOTE_RE, "\\'")}'`;
if (typeof value === 'number') return `${value}`;
Expand All @@ -60,19 +63,55 @@ export function serializeJS(value: unknown, options?: {comments?: Record<string,
if (typeof value === 'object')
return `{
${Object.entries(value)
.map(([k, v]) => `${comments[k] ? `${indent(`/** ${comments[k]} */`, indentLv + 1)}\n` : ''}${indent(objKey(k), indentLv + 1)}: ${serializeJS(v, {indentLv: indentLv + 1})}`)
.map(([k, v]) => {
const nextCommentPath = commentPath === '' ? k : `${commentPath}.${k}`;
const comment = comments[nextCommentPath] ? `${indent(`/** ${comments[nextCommentPath]} */`, indentLv + 1)}\n` : '';

return `${comment}${indent(objKey(k), indentLv + 1)}: ${serializeJS(v, {
comments,
commentPath: nextCommentPath,
indentLv: indentLv + 1,
})}`;
})
.join(',\n')},
${indent(`}${indentLv === 0 ? ';' : ''}`, indentLv)}`;
throw new Error(`Could not serialize ${value}`);
}

/** serialize TS ref into string */
export function serializeTS(value: unknown, options?: {indentLv?: number}): string {
const indentLv = options?.indentLv || 0;
if (typeof value === 'number' || typeof value === 'string') return `${value}`;
if (value === undefined) return 'undefined';
if (value === null) return 'null';
if (Array.isArray(value)) return `[${value.map((item) => serializeTS(item, {indentLv: indentLv + 1})).join(', ')}]`;
if (typeof value === 'function') throw new Error(`Cannot serialize function ${value}`);
if (typeof value === 'object')
return `{
${Object.entries(value)
.map(([k, v]) => `${indent(objKey(k), indentLv + 1)}: ${serializeTS(v, {indentLv: indentLv + 1})}`)
.join(';\n')};
${indent(`}${indentLv === 0 ? ';' : ''}`, indentLv)}`;
throw new Error(`Could not serialize ${value}`);
}

function defaultTransform(token: ParsedToken, mode?: string): (typeof token)['$value'] {
if (!mode || !token.$extensions?.mode || !(mode in token.$extensions.mode) || !token.$extensions.mode[mode]) return token.$value;
const modeVal = token.$extensions.mode[mode];
if (typeof modeVal === 'string' || Array.isArray(modeVal) || typeof modeVal === 'number') return modeVal;
return {...(token.$value as typeof modeVal), ...modeVal};
}

function setToken(obj: Record<string, any>, id: string, value: any, nest = false) {
if (nest) {
return set(obj, id, value);
}

obj[id] = value;

return obj;
}

export default function pluginJS(options?: Options): Plugin {
if (options && options.js === false && options.json === false) throw new Error(`[plugin-js] Must output either JS or JSON. Received "js: false" and "json: false"`);

Expand Down Expand Up @@ -103,33 +142,48 @@ export default function pluginJS(options?: Options): Plugin {
meta: {},
modes: {},
};
const ts: TSResult = {tokens: [], meta: [], modes: []};
const ts: TSResult = {tokens: {}, meta: {}, modes: {}};
const transform = (typeof options?.transform === 'function' && options.transform) || defaultTransform;
for (const token of tokens) {
js.tokens[token.id] = await transform(token);
setToken(js.tokens, token.id, await transform(token), options?.deep);
if (buildTS) {
const t = tokenTypes[token.$type];
ts.tokens.push(indent(`${objKey(token.id)}: ${t}['$value'];`, 1));
setToken(ts.tokens, token.id, `${t}['$value']`, options?.deep);
tsImports.add(t);
}
js.meta![token.id] = {
_original: cloneDeep(token._original),
...((token._group && {_group: token._group}) || {}),
...cloneDeep(token as any),
};
setToken(
js.meta!,
token.id,
{
_original: cloneDeep(token._original),
...((token._group && {_group: token._group}) || {}),
...cloneDeep(token as any),
},
options?.deep,
);
if (buildTS) {
const t = `Parsed${tokenTypes[token.$type]}`;
ts.meta!.push(indent(`${objKey(token.id)}: ${t}${token.$extensions?.mode ? ` & { $extensions: { mode: typeof modes['${token.id}'] } }` : ''};`, 1));
const modeAccessor = options?.deep ? token.id.replace('.', "']['") : token.id;
setToken(ts.meta!, token.id, `${t}${token.$extensions?.mode ? ` & { $extensions: { mode: typeof modes['${modeAccessor}'] } }` : ''}`, options?.deep);
tsImports.add(t);
}
if (token.$extensions?.mode) {
js.modes[token.id] = {};
if (buildTS) ts.modes.push(indent(`${objKey(token.id)}: {`, 1));
setToken(js.modes, token.id, {}, options?.deep);
if (buildTS) setToken(ts.modes, token.id, {}, options?.deep);
for (const modeName of Object.keys(token.$extensions.mode)) {
js.modes[token.id]![modeName] = await transform(token, modeName);
if (buildTS) ts.modes.push(indent(`${objKey(modeName)}: ${tokenTypes[token.$type]}['$value'];`, 2));
if (options?.deep) {
setToken(js.modes, `${token.id}.${modeName}`, await transform(token, modeName), true);
} else {
js.modes[token.id]![modeName] = await transform(token, modeName);
}
if (buildTS) {
if (options?.deep) {
setToken(ts.modes, `${token.id}.${modeName}`, `${tokenTypes[token.$type]}['$value']`, true);
} else {
(ts.modes[token.id] as TSResult['modes'])[modeName] = `${tokenTypes[token.$type]}['$value']`;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😅 I’m so sorry you had to wade through the manual .d.ts file generation. I don’t think a lot of this code was written as well as I’d like to be easy to refactor / work on.

But this all looks fantastic 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly it wasn’t that bad, it was just very specific! You did most of the work with the serializer function 😄

Copy link
Collaborator

@drwpow drwpow Aug 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a lot of codegen stuff, taking a more AST approach is “cleaner” in some ways. But you pay for it in other ways in terms of upfront timesink / wading through the land of obscure bugs, not to mention when you set it down for a while, the confusion of “wait how does this thing work again?”

String mashing is sloppy but it’s real easy to compare “output good” vs “output bad” and know exactly where to make the adjustment

}
}
}
if (buildTS) ts.modes.push(indent('};', 1));
}
}

Expand Down Expand Up @@ -189,12 +243,10 @@ export function token(tokenID, modeName) {
...sortedTypeImports.map((m) => indent(`${m},`, 1)),
`} from '@cobalt-ui/core';`,
'',
'export declare const tokens: {',
...ts.tokens,
'};',
`export declare const tokens: ${serializeTS(ts.tokens)}`,
'',
...(ts.meta ? ['export declare const meta: {', ...ts.meta, '};', ''] : []),
`export declare const modes: ${ts.modes.length ? `{\n${ts.modes.join('\n')}\n}` : 'Record<string, never>'};`,
...(ts.meta ? [`export declare const meta: ${serializeTS(ts.meta)}`, ''] : []),
`export declare const modes: ${Object.keys(ts.modes).length ? serializeTS(ts.modes) : 'Record<string, never>;'}`,
'',
`export declare function token<K extends keyof typeof tokens>(tokenID: K, modeName?: never): typeof tokens[K];`,
`export declare function token<K extends keyof typeof modes, M extends keyof typeof modes[K]>(tokenID: K, modeName: M): typeof modes[K][M];`,
Expand Down
21 changes: 21 additions & 0 deletions packages/plugin-js/test/build.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,25 @@ describe('@cobalt-ui/plugin-js', () => {
expect(fs.readFileSync(new URL('actual.json', cwd), 'utf8'), `${dir}: JSON`).toBe(fs.readFileSync(new URL('want.json', cwd), 'utf8'));
});
});

describe('nested output', () => {
test('nested output is correct', async () => {
const cwd = new URL(`./nested/`, import.meta.url);
const tokens = JSON.parse(fs.readFileSync(new URL('tokens.json', cwd)));
await build(tokens, {
outDir: cwd,
plugins: [
pluginJS({
js: 'actual.js',
json: 'actual.json',
deep: true,
}),
],
color: {},
});
expect(fs.readFileSync(new URL('actual.js', cwd), 'utf8'), `nested: JS`).toBe(fs.readFileSync(new URL('want.js', cwd), 'utf8'));
expect(fs.readFileSync(new URL('actual.d.ts', cwd), 'utf8'), `nested: TS`).toBe(fs.readFileSync(new URL('want.d.ts', cwd), 'utf8'));
expect(fs.readFileSync(new URL('actual.json', cwd), 'utf8'), `nested: JSON`).toBe(fs.readFileSync(new URL('want.json', cwd), 'utf8'));
});
});
});
46 changes: 46 additions & 0 deletions packages/plugin-js/test/nested/tokens.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"color": {
"$type": "color",
"$description": "Brand blue",
"black": {"$value": "#00193f"},
"blue": {
"00": {"$value": "{color.black}"},
"10": {"$value": "#062053"},
"20": {"$value": "#0f2868"},
"30": {"$value": "#192f7d"},
"40": {"$value": "#223793"},
"50": {
"$description": "Medium blue",
"$value": "#2b3faa"
},
"60": {"$value": "#3764ba"},
"70": {"$value": "#4887c9"},
"80": {"$value": "#5ca9d7"},
"90": {"$value": "#72cce5"},
"100": {"$value": "#89eff1"}
},
"white": {"$value": "#ffffff"}
},
"ui": {
"fg": {
"$type": "color",
"$value": "{color.black}",
"$extensions": {
"mode": {
"light": "{color.black}",
"dark": "{color.white}"
}
}
},
"bg": {
"$type": "color",
"$value": "{color.white}",
"$extensions": {
"mode": {
"light": "{color.white}",
"dark": "{color.black}"
}
}
}
}
}
74 changes: 74 additions & 0 deletions packages/plugin-js/test/nested/want.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Design Tokens
* Autogenerated from tokens.json.
* DO NOT EDIT!
*/

import {
ColorToken,
ParsedColorToken,
} from '@cobalt-ui/core';

export declare const tokens: {
color: {
black: ColorToken['$value'];
blue: {
10: ColorToken['$value'];
20: ColorToken['$value'];
30: ColorToken['$value'];
40: ColorToken['$value'];
50: ColorToken['$value'];
60: ColorToken['$value'];
70: ColorToken['$value'];
80: ColorToken['$value'];
90: ColorToken['$value'];
100: ColorToken['$value'];
'00': ColorToken['$value'];
};
white: ColorToken['$value'];
};
ui: {
fg: ColorToken['$value'];
bg: ColorToken['$value'];
};
};

export declare const meta: {
color: {
black: ParsedColorToken;
blue: {
10: ParsedColorToken;
20: ParsedColorToken;
30: ParsedColorToken;
40: ParsedColorToken;
50: ParsedColorToken;
60: ParsedColorToken;
70: ParsedColorToken;
80: ParsedColorToken;
90: ParsedColorToken;
100: ParsedColorToken;
'00': ParsedColorToken;
};
white: ParsedColorToken;
};
ui: {
fg: ParsedColorToken & { $extensions: { mode: typeof modes['ui']['fg'] } };
bg: ParsedColorToken & { $extensions: { mode: typeof modes['ui']['bg'] } };
};
};

export declare const modes: {
ui: {
fg: {
light: ColorToken['$value'];
dark: ColorToken['$value'];
};
bg: {
light: ColorToken['$value'];
dark: ColorToken['$value'];
};
};
};

export declare function token<K extends keyof typeof tokens>(tokenID: K, modeName?: never): typeof tokens[K];
export declare function token<K extends keyof typeof modes, M extends keyof typeof modes[K]>(tokenID: K, modeName: M): typeof modes[K][M];
Loading