Skip to content

Commit

Permalink
feat(design): add Sass renderer support
Browse files Browse the repository at this point in the history
deprecate(design): duplicated `TokenReferenceRender` interface
  • Loading branch information
kpanot committed Oct 9, 2024
1 parent 8c5e9d2 commit a31a26c
Show file tree
Hide file tree
Showing 17 changed files with 338 additions and 21 deletions.
5 changes: 5 additions & 0 deletions packages/@o3r/design/schemas/design-token.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@
"o3rRatio": {
"description": "Ratio to apply to previous value. The ratio will be applied only on token with \"number\" type or on the first numbers determined in \"string\" like types.\nIn case of complex type (such as shadow, transition, etc...), the ratio will be applied to all numeric types in it.",
"type": "number"
},
"o3rExpectOverride": {
"description": "Indicate that the token expect to be overridden by external rules",
"type": "boolean",
"default": false
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export interface DesignTokenGroupExtensions {
* In case of complex type (such as shadow, transition, etc...), the ratio will be applied to all numeric types in it.
*/
o3rRatio?: number;
/**
* Indicate that the token expect to be overridden by external rules
*/
o3rExpectOverride?: boolean;
}

/** Design Token Extension fields supported by the default renderer */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,27 @@ export type TokenValueRenderer = (tokenStructure: DesignTokenVariableStructure,
* Function rendering the Design Token Reference
* @param tokenStructure Parsed Design Token
* @param variableSet Complete list of the parsed Design Token
* @param defaultValue Default value to use if the reference is made to an undefined variable
*/
// eslint-disable-next-line no-use-before-define
export type TokenReferenceRender = (tokenStructure: DesignTokenVariableStructure, variableSet: Map<string, DesignTokenVariableStructure>) => string;
export type TokenReferenceRenderer = (tokenStructure: DesignTokenVariableStructure, variableSet: Map<string, DesignTokenVariableStructure>, defaultValue?: string) => string;

/**
* Function rendering the Design Token Reference not registered
* @param referenceName Name of the un registered variable
* @param variableSet Complete list of the parsed Design Token
*/
// eslint-disable-next-line no-use-before-define
export type UnregisteredTokenReferenceRender = (referenceName: string, variableSet: Map<string, DesignTokenVariableStructure>) => string;
export type UnregisteredTokenReferenceRenderer = (referenceName: string, variableSet: Map<string, DesignTokenVariableStructure>) => string;

/**
* Function rendering the Design Token Reference
* @param tokenStructure Parsed Design Token
* @param variableSet Complete list of the parsed Design Token
* @deprecated duplicate of {@link TokenReferenceRenderer}, will be removed on v13
*/
// eslint-disable-next-line no-use-before-define
export type TokenReferenceRenderer = (tokenStructure: DesignTokenVariableStructure, variableSet: Map<string, DesignTokenVariableStructure>) => string;
export type TokenReferenceRender = (tokenStructure: DesignTokenVariableStructure, variableSet: Map<string, DesignTokenVariableStructure>) => string;


/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,50 @@ describe('Design Token Parser', () => {
expect(item).toBeDefined();
expect(item.extensions.o3rImportant).toBe(false);
});

test('should generate a variable with template when token matching star', () => {
const result = parser.parseDesignToken({
...exampleVariableWithContext,
context: {
template: {
example: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'*': {
$extensions: {
o3rImportant: true
}
}
}
} as DesignTokenGroupTemplate
}
});
const item = result.get('example.var1');

expect(item).toBeDefined();
expect(item.extensions.o3rImportant).toBe(true);
expect(item.extensions.o3rPrivate).toBeFalsy();
});

test('should generate a variable with template when token path matching star', () => {
const result = parser.parseDesignToken({
...exampleVariableWithContext,
context: {
template: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'*': {
$extensions: {
o3rImportant: true
}
}
} as DesignTokenGroupTemplate
}
});
const item = result.get('example.var1');

expect(item).toBeDefined();
expect(item.extensions.o3rImportant).toBe(true);
expect(item.extensions.o3rPrivate).toBeFalsy();
});
});

test('should generate a complex variable', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
DesignTokenGroup,
DesignTokenGroupExtensions,
DesignTokenGroupTemplate,
DesignTokenMetadata,
DesignTokenNode,
DesignTokenSpecification
} from '../design-token-specification.interface';
Expand All @@ -25,9 +26,23 @@ const getTokenReferenceName = (tokenName: string, parents: string[]) => parents.
const getExtensions = (nodes: NodeReference[], context: DesignTokenContext | undefined) => {
return nodes.reduce((acc, {tokenNode}, i) => {
const nodeNames = nodes.slice(0, i + 1).map(({ name }) => name);
const defaultMetadata = nodeNames.length ? nodeNames.reduce((accTpl, name) => accTpl?.[name] as DesignTokenGroupTemplate, context?.template) : undefined;
const o3rMetadata = { ...defaultMetadata?.$extensions?.o3rMetadata, ...acc.o3rMetadata, ...tokenNode.$extensions?.o3rMetadata };
return ({ ...acc, ...defaultMetadata?.$extensions, ...tokenNode.$extensions, o3rMetadata });
const defaultMetadata = nodeNames.length
? nodeNames.reduce((accTplList, name) =>
accTplList
.flatMap((accTpl) => ([accTpl[name], accTpl['*']] as (DesignTokenGroupTemplate | undefined)[]))
.filter((accTpl): accTpl is DesignTokenGroupTemplate => !!accTpl)
, context?.template ? [context.template] : [])
: undefined;
const o3rMetadata = {
...defaultMetadata?.reduce((accNode: DesignTokenMetadata, node) => ({...accNode, ...node.$extensions?.o3rMetadata}), {}),
...acc.o3rMetadata,
...tokenNode.$extensions?.o3rMetadata
};
return ({
...acc,
...defaultMetadata?.reduce((accNode, node) => ({ ...accNode, ...node.$extensions }), acc),
...tokenNode.$extensions, o3rMetadata
});
}, {} as DesignTokenGroupExtensions & DesignTokenExtensions);
};
const getReferences = (cssRawValue: string) => Array.from(cssRawValue.matchAll(tokenReferenceRegExp)).map(([,tokenRef]) => tokenRef);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,14 @@ describe('getMetadataTokenDefinitionRenderer', () => {
expect(privateDefinitionRenderer).toHaveBeenCalledTimes(1);
});

test('should enforce reference when expecting override', () => {
const tokenValueRenderer = jest.fn().mockReturnValue(JSON.stringify({ name: 'test-var', value: 'test-value' }));
const renderer = getCssTokenDefinitionRenderer({ tokenValueRenderer });
const variable = designTokens.get('example.var-expect-override');

renderer(variable, designTokens);
expect(variable).toBeDefined();
expect(tokenValueRenderer).toHaveBeenCalledTimes(1);
expect(tokenValueRenderer).toHaveBeenCalledWith(expect.objectContaining({}), expect.objectContaining({}), true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ export const getCssTokenDefinitionRenderer = (options?: CssTokenDefinitionRender
const renderer = (variable: DesignTokenVariableStructure, variableSet: Map<string, DesignTokenVariableStructure>) => {
let variableString: string | undefined;
if (!isPrivateVariable(variable)) {
variableString = `--${variable.getKey(tokenVariableNameRenderer)}: ${tokenValueRenderer(variable, variableSet)};`;
variableString = `--${variable.getKey(tokenVariableNameRenderer)}: ${tokenValueRenderer(variable, variableSet, !!variable.extensions.o3rExpectOverride)};`;
if (variable.extensions.o3rScope) {
variableString = `${variable.extensions.o3rScope} { ${variableString} }`;
}
} else if (options?.privateDefinitionRenderer && variable.extensions.o3rPrivate) {
} else if (options?.privateDefinitionRenderer) {
variableString = options.privateDefinitionRenderer(variable, variableSet);
}
if (variableString && variable.description) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export interface CssStyleContentUpdaterOptions {
* @default {@see AUTO_GENERATED_END}
*/
endTag?: string;

/**
* Generate the variable in a :root scope in first generation
* @default true
*/
scopeOnNewFile?: boolean;
}

/**
Expand All @@ -33,6 +39,7 @@ export interface CssStyleContentUpdaterOptions {
export const getCssStyleContentUpdater = (options?: CssStyleContentUpdaterOptions): DesignContentFileUpdater => {
const startTag = options?.startTag || AUTO_GENERATED_START;
const endTag = options?.endTag || AUTO_GENERATED_END;
const scopeOnNewFile = options?.scopeOnNewFile ?? true;

/** Regexp to replace the content between the detected tags. It also handle possible inputted special character sanitization */
const regexToReplace = new RegExp(`${startTag.replace(SANITIZE_TAG_INPUTS_REGEXP, '\\$&')}(:?(.|[\n\r])*)${endTag.replace(SANITIZE_TAG_INPUTS_REGEXP, '\\$&')}`);
Expand All @@ -41,7 +48,7 @@ export const getCssStyleContentUpdater = (options?: CssStyleContentUpdaterOption
if (styleContent.indexOf(startTag) >= 0 && styleContent.indexOf(endTag) >= 0) {
return styleContent.replace(regexToReplace, generateVars(variables, startTag, endTag));
} else {
return styleContent + '\n' + generateVars(variables, startTag, endTag, true);
return styleContent + '\n' + generateVars(variables, startTag, endTag, scopeOnNewFile);
}
};
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DesignTokenVariableStructure, TokenKeyRenderer, TokenReferenceRender, TokenValueRenderer, UnregisteredTokenReferenceRender } from '../../parsers/design-token-parser.interface';
import type { DesignTokenVariableStructure, TokenKeyRenderer, TokenReferenceRenderer, TokenValueRenderer, UnregisteredTokenReferenceRenderer } from '../../parsers/design-token-parser.interface';
import { isO3rPrivateVariable } from '../design-token.renderer.helpers';
import type { Logger } from '@o3r/core';

Expand All @@ -18,13 +18,13 @@ export interface CssTokenValueRendererOptions {
/**
* Render for the reference to Design Token
*/
referenceRenderer?: TokenReferenceRender;
referenceRenderer?: TokenReferenceRenderer;

/**
* Render for the reference to unregistered Design Token
* Note: the default renderer display a warning message when called
*/
unregisteredReferenceRenderer?: UnregisteredTokenReferenceRender;
unregisteredReferenceRenderer?: UnregisteredTokenReferenceRenderer;

/**
* Custom logger
Expand All @@ -45,9 +45,9 @@ export const getCssTokenValueRenderer = (options?: CssTokenValueRendererOptions)
const isPrivateVariable = options?.isPrivateVariable || isO3rPrivateVariable;
const tokenVariableNameRenderer = options?.tokenVariableNameRenderer;

const defaultReferenceRenderer = (variable: DesignTokenVariableStructure, variableSet: Map<string, DesignTokenVariableStructure>): string => {
const defaultReferenceRenderer: TokenReferenceRenderer = (variable, variableSet, defaultValue): string => {
if (!isPrivateVariable(variable)) {
return `var(--${variable.getKey(tokenVariableNameRenderer)})`;
return `var(--${variable.getKey(tokenVariableNameRenderer)}${defaultValue ? ', ' + defaultValue : ''})`;
} else {
// eslint-disable-next-line no-use-before-define
return `var(--${variable.getKey(tokenVariableNameRenderer)}, ${renderer(variable, variableSet)})`;
Expand All @@ -56,17 +56,20 @@ export const getCssTokenValueRenderer = (options?: CssTokenValueRendererOptions)

const defaultUnregisteredReferenceRenderer = (variableName: string, _variableSet: Map<string, DesignTokenVariableStructure>): string => {
const cssVarName = `var(--${variableName.replace(/[. ]+/g, '-')})`;
options?.logger?.debug?.(`Variable "${variableName}" not registered, will be renderer as "${cssVarName}"`);
options?.logger?.debug?.(`Variable "${variableName}" not is registered, it will be renderer as "${cssVarName}"`);
return cssVarName;
};

const referenceRenderer = options?.referenceRenderer || defaultReferenceRenderer;
const unregisteredReferenceRenderer = options?.unregisteredReferenceRenderer || defaultUnregisteredReferenceRenderer;

const renderer = (variable: DesignTokenVariableStructure, variableSet: Map<string, DesignTokenVariableStructure>, enforceReferenceRendering = false) => {
let variableValue = enforceReferenceRendering ? referenceRenderer(variable, variableSet) : variable.getCssRawValue(variableSet).replaceAll(/\{([^}]*)\}/g, (_defaultValue, matcher: string) =>
let variableValue = variable.getCssRawValue(variableSet).replaceAll(/\{([^}]*)\}/g, (_defaultValue, matcher: string) =>
(variableSet.has(matcher) ? referenceRenderer(variableSet.get(matcher)!, variableSet) : unregisteredReferenceRenderer(matcher, variableSet))
);
if (enforceReferenceRendering) {
variableValue = referenceRenderer(variable, variableSet, variableValue);
}
variableValue += variableValue && variable.extensions.o3rImportant ? ' !important' : '';
return variableValue;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export const computeFileToUpdatePath = (root = process.cwd(), defaultFile = 'sty

/**
* Compare the Token Variable by name
* @param a first token variable
* @param b second token variable
*/
export const compareVariableByName = (a: DesignTokenVariableStructure, b: DesignTokenVariableStructure): number => a.getKey().localeCompare(b.getKey());

Expand Down Expand Up @@ -50,8 +52,8 @@ export const renderDesignTokens = async (variableSet: DesignTokenVariableSet, op
.reduce((acc, designToken) => {
const filePath = determineFileToUpdate(designToken);
const variable = tokenDefinitionRenderer(designToken, variableSet);
acc[filePath] ||= [];
if (variable) {
acc[filePath] ||= [];
acc[filePath].push(variable);
}
return acc;
Expand All @@ -60,6 +62,9 @@ export const renderDesignTokens = async (variableSet: DesignTokenVariableSet, op
await Promise.all(
Object.entries(updates).map(async ([file, vars]) => {
const styleContent = existsFile(file) ? await readFile(file) : '';
if (!existsFile(file) && !vars.length) {
return;
}
const newStyleContent = styleContentUpdater(vars, file, styleContent);
await writeFile(file, newStyleContent);
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ describe('getSassTokenDefinitionRenderer', () => {
expect(result).toBe('$example-var1: test-value;');
});

test('should happen default when expecting override', () => {
const tokenValueRenderer = jest.fn().mockReturnValue('test-value');
const renderer = getSassTokenDefinitionRenderer({ tokenValueRenderer });
const variable = designTokens.get('example.var-expect-override');

const result = renderer(variable, designTokens);
expect(variable).toBeDefined();
expect(tokenValueRenderer).toHaveBeenCalledTimes(1);
expect(result).toBeDefined();
expect(result).toBe('$example-var-expect-override: test-value !default;');
});

test('should prefix private variable', () => {
const tokenVariableNameRenderer: TokenKeyRenderer = (v) => '_' + tokenVariableNameSassRenderer(v);

Expand All @@ -39,6 +51,6 @@ describe('getSassTokenDefinitionRenderer', () => {
expect(variable).toBeDefined();
expect(tokenValueRenderer).toHaveBeenCalledTimes(1);
expect(result).toBeDefined();
expect(result).toBe('$_example-var1-private: var(--example-var1-private, #000);');
expect(result).toBe('/// @access private\n$_example-var1-private: var(--example-var1-private, #000);');
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { DesignTokenVariableStructure, TokenKeyRenderer, TokenValueRenderer } from '../../parsers/design-token-parser.interface';
import type { TokenDefinitionRenderer } from '../design-token.renderer.interface';
import { getCssTokenValueRenderer } from '../css/design-token-value.renderers';
import type { Logger } from '@o3r/core';
import { isO3rPrivateVariable } from '../design-token.renderer.helpers';
import { getSassTokenValueRenderer } from './design-token-value.renderers';

export interface SassTokenDefinitionRendererOptions {

Expand All @@ -14,6 +15,12 @@ export interface SassTokenDefinitionRendererOptions {
*/
tokenVariableNameRenderer?: TokenKeyRenderer;

/**
* Determine if the variable is private and should not be rendered
* @default {@see isO3rPrivateVariable}
*/
isPrivateVariable?: (variable: DesignTokenVariableStructure) => boolean;

/**
* Custom logger
* Nothing will be logged if not provided
Expand All @@ -35,11 +42,20 @@ export const tokenVariableNameSassRenderer: TokenKeyRenderer = (variable) => {
* @returns
*/
export const getSassTokenDefinitionRenderer = (options?: SassTokenDefinitionRendererOptions): TokenDefinitionRenderer => {
const tokenValueRenderer = options?.tokenValueRenderer || getCssTokenValueRenderer({ logger: options?.logger });
const tokenValueRenderer = options?.tokenValueRenderer || getSassTokenValueRenderer({ logger: options?.logger });
const keyRenderer = options?.tokenVariableNameRenderer || tokenVariableNameSassRenderer;
const isPrivateVariable = options?.isPrivateVariable || isO3rPrivateVariable;

const renderer = (variable: DesignTokenVariableStructure, variableSet: Map<string, DesignTokenVariableStructure>) => {
return `$${variable.getKey(keyRenderer)}: ${ tokenValueRenderer(variable, variableSet, true) };`;
let variableString = `$${variable.getKey(keyRenderer)}: ${ tokenValueRenderer(variable, variableSet) }${ variable.extensions.o3rExpectOverride ? ' !default' : '' };`;
if (isPrivateVariable(variable)) {
variableString = '/// @access private\n' + variableString;
}
if (variable.description){
variableString = variable.description.split(/[\n\r]+/).map((line) => `/// ${line}`).join('\n') + '\n' + variableString;
}

return variableString;
};
return renderer;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { type CssStyleContentUpdaterOptions, getCssStyleContentUpdater } from '../css';
import type { DesignContentFileUpdater } from '../design-token.renderer.interface';

/** Options for {@link getSassStyleContentUpdater} */
export interface SassStyleContentUpdaterOptions extends Exclude<CssStyleContentUpdaterOptions, 'scopeOnNewFile'> {
}

/**
* Retrieve a Content Updater function for SASS generator
* @param options
*/
export const getSassStyleContentUpdater = (options?: SassStyleContentUpdaterOptions): DesignContentFileUpdater => {
return getCssStyleContentUpdater({ ...options, scopeOnNewFile: false });
};
Loading

0 comments on commit a31a26c

Please sign in to comment.