diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a04e471..d9ba9dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,21 +11,25 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - - run: npm install --ignore-scripts --no-audit --no-fund + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + cache: "npm" + node-version: 22 + - run: npm ci --ignore-scripts --no-audit --no-fund - run: npm test publish-npm: needs: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 with: - cache: 'npm' + node-version: 22 + cache: "npm" registry-url: https://registry.npmjs.org/ - - run: npm install --ignore-scripts --no-audit --no-fund + - run: npm ci --ignore-scripts --no-audit --no-fund - run: npm run build - run: npm publish --public env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index adf4214..91ca824 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,11 +14,12 @@ jobs: name: Unit tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: cache: "npm" + node-version: 22 - run: npm install --ignore-scripts --no-audit --no-fund - run: npm test @@ -26,11 +27,12 @@ jobs: name: Check types runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: cache: "npm" + node-version: 22 - run: npm install --ignore-scripts --no-audit --no-fund - run: npm run check @@ -39,11 +41,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: cache: "npm" + node-version: 22 - run: npm install --ignore-scripts --no-audit --no-fund - name: Build package run: npm run build diff --git a/package.json b/package.json index e5b7f6e..3790988 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "default": "./dist/css-design-tokens.js" }, "engines": { - "node": ">=18" + "node": ">=22" }, "scripts": { "test": "vitest run", diff --git a/src/destructure-line-height.test.ts b/src/destructure-line-height.test.ts index a77ea53..cadd21a 100644 --- a/src/destructure-line-height.test.ts +++ b/src/destructure-line-height.test.ts @@ -22,6 +22,7 @@ test('percentage', () => { test('number', () => { expect.soft(destructure_line_height('1')).toEqual(1) + expect.soft(destructure_line_height('1.0')).toEqual(1) expect.soft(destructure_line_height('1.1')).toEqual(1.1) expect.soft(destructure_line_height('1e2')).toEqual(100) }) @@ -36,6 +37,15 @@ test('length', () => { expect.soft(destructure_line_height('1e2em')).toEqual({ value: 100, unit: 'em' }) }) +test('zero', () => { + expect.soft(destructure_line_height('0%')).toEqual(0) + expect.soft(destructure_line_height('0.0%')).toEqual(0) + expect.soft(destructure_line_height('0px')).toEqual(0) + expect.soft(destructure_line_height('0.0px')).toEqual(0) + expect.soft(destructure_line_height('0')).toEqual(0) + expect.soft(destructure_line_height('0.0')).toEqual(0) +}) + test('unprocessable values', () => { expect.soft(destructure_line_height('var(--my-line-height)')).toEqual(null) expect.soft(destructure_line_height('var(--my-line-height, 1.2)')).toEqual(null) diff --git a/src/destructure-line-height.ts b/src/destructure-line-height.ts index d695f21..c7e9425 100644 --- a/src/destructure-line-height.ts +++ b/src/destructure-line-height.ts @@ -21,8 +21,12 @@ export function destructure_line_height(value: string): Length | number | null { switch (maybe_dimension.type) { case 'Dimension': { + let value = Number(maybe_dimension.value) + if (value === 0) { + return 0 + } return { - value: Number(maybe_dimension.value), + value, unit: maybe_dimension.unit } } diff --git a/src/index.test.ts b/src/index.test.ts index 740f5b3..114aa12 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -17,7 +17,7 @@ describe('analysis_to_tokens', () => { let expected = { color: { - 'green-5e0cf03': { + 'green-3fc58faf': { $type: 'color', $value: { colorSpace: 'srgb', @@ -85,7 +85,7 @@ describe('css_to_tokens', () => { } `) expect(actual.color).toEqual({ - 'green-5e0cf03': { + 'green-3fc58faf': { $type: 'color', $value: { colorSpace: 'srgb', @@ -98,7 +98,7 @@ describe('css_to_tokens', () => { [EXTENSION_CSS_PROPERTIES]: ['color', 'border-color'], } }, - 'grey-8139d9b': { + 'grey-d68e53e4': { $type: 'color', $value: { colorSpace: 'srgb', @@ -130,7 +130,7 @@ describe('css_to_tokens', () => { } `) expect(actual.color).toEqual({ - 'black-991c5d52': { + 'black-edec3e7a': { $type: 'color', $value: { colorSpace: 'srgb', @@ -159,9 +159,52 @@ describe('css_to_tokens', () => { test('extensions[css-properties] does not yield a type error', () => { let actual = css_to_tokens('a { color: green; }') // Not so much interested in the test result, more looking that this isn't giving a type error - let properties = actual.color['green-5e0cf03']!['$extensions'][EXTENSION_CSS_PROPERTIES] + let properties = actual.color['green-3fc58faf']!['$extensions'][EXTENSION_CSS_PROPERTIES] expect(properties).toEqual(['color']) }) + + test('deduplicates colors', () => { + let actual = css_to_tokens(` + .my-design-system { + color: green; + border-color: rgb(0, 128, 0); + background-color: #008000; + color: #008000; + } + .alpha { + border-color: rgb(0 128 0 / 0.5); + border-color: rgb(0 128 0 / .5); + } + `) + expect(actual.color).toEqual({ + 'green-3fc58faf': { + $type: 'color', + $value: { + colorSpace: 'srgb', + components: [0, 0.5019607843137255, 0], + alpha: 1, + }, + $extensions: { + [EXTENSION_AUTHORED_AS]: 'green', + [EXTENSION_USAGE_COUNT]: 4, + [EXTENSION_CSS_PROPERTIES]: ['color', 'border-color', 'background-color'], + } + }, + 'green-64a061f5': { + $type: 'color', + $value: { + colorSpace: 'srgb', + components: [0, 0.5019607843137255, 0], + alpha: 0.5, + }, + $extensions: { + [EXTENSION_AUTHORED_AS]: 'rgb(0 128 0 / 0.5)', + [EXTENSION_USAGE_COUNT]: 2, + [EXTENSION_CSS_PROPERTIES]: ['border-color'], + } + }, + }) + }) }) describe('font sizes', () => { @@ -185,6 +228,26 @@ describe('css_to_tokens', () => { }, }) }) + test('handles `0`', () => { + let actual = css_to_tokens(` + .my-design-system { + font-size: 0; + } + `) + expect(actual.font_size).toEqual({ + 'fontSize-c238': { + $type: 'dimension', + $value: { + value: 0, + unit: 'px' + }, + $extensions: { + [EXTENSION_AUTHORED_AS]: '0', + [EXTENSION_USAGE_COUNT]: 1, + } + }, + }) + }) test('outputs a value type when not using rem or px', () => { let actual = css_to_tokens(` @@ -236,6 +299,57 @@ describe('css_to_tokens', () => { }, }) }) + + test('dedupes identical sizes', () => { + let actual = css_to_tokens(` + .my-design-system { + font-size: 16px; + font-size: 16.0PX; + + font-size: 0.5rem; + font-size: .5rem; + + font-size: 0; + font-size: 0px; + font-size: 0rem; + } + `) + expect(actual.font_size).toEqual({ + 'fontSize-171eed': { + $type: 'dimension', + $value: { + value: 16, + unit: 'px' + }, + $extensions: { + [EXTENSION_AUTHORED_AS]: '16px', + [EXTENSION_USAGE_COUNT]: 2, + } + }, + 'fontSize-548aa743': { + $type: 'dimension', + $value: { + unit: 'rem', + value: 0.5, + }, + $extensions: { + [EXTENSION_AUTHORED_AS]: '0.5rem', + [EXTENSION_USAGE_COUNT]: 2, + }, + }, + 'fontSize-c238': { + $type: 'dimension', + $value: { + unit: 'px', + value: 0, + }, + $extensions: { + [EXTENSION_AUTHORED_AS]: '0', + [EXTENSION_USAGE_COUNT]: 3, + }, + }, + }) + }) }) describe('font families', () => { @@ -280,6 +394,64 @@ describe('css_to_tokens', () => { }) }) + test('dedupes line heights', () => { + let actual = css_to_tokens(` + .my-design-system { + line-height: 1; + line-height: 1.0; + + line-height: 1rem; + line-height: 1.0rem; + + line-height: 1px; + line-height: 1.0px; + + line-height: 0; + line-height: 0.0; + } + `) + expect(actual.line_height).toEqual({ + 'lineHeight-31': { + $type: 'number', + $value: 1, + $extensions: { + [EXTENSION_AUTHORED_AS]: '1', + [EXTENSION_USAGE_COUNT]: 2, + } + }, + 'lineHeight-17fec9': { + $type: 'dimension', + $value: { + value: 1, + unit: 'rem' + }, + $extensions: { + [EXTENSION_AUTHORED_AS]: '1rem', + [EXTENSION_USAGE_COUNT]: 2, + } + }, + 'lineHeight-c5f9': { + $type: 'dimension', + $value: { + value: 1, + unit: 'px' + }, + $extensions: { + [EXTENSION_AUTHORED_AS]: '1px', + [EXTENSION_USAGE_COUNT]: 2, + } + }, + 'lineHeight-30': { + $type: 'number', + $value: 0, + $extensions: { + [EXTENSION_AUTHORED_AS]: '0', + [EXTENSION_USAGE_COUNT]: 2, + } + }, + }) + }) + test('outputs a number type when using a number', () => { let actual = css_to_tokens(` .my-design-system { @@ -467,10 +639,10 @@ describe('css_to_tokens', () => { describe('duration', () => { test('outputs a token when using a valid duration', () => { let actual = css_to_tokens(` - .my-design-system { - animation-duration: 1s; - } - `) + .my-design-system { + animation-duration: 1s; + } + `) expect(actual.duration).toEqual({ 'duration-17005f': { $type: 'duration', @@ -488,12 +660,12 @@ describe('css_to_tokens', () => { test('outputs an unparsed token when using an invalid duration', () => { let actual = css_to_tokens(` - .my-design-system { - animation-duration: var(--test); - } - `) + .my-design-system { + animation-duration: var(--test); + } + `) expect(actual.duration).toEqual({ - 'duration-452f2b3b': { + 'duration-f9e24f32': { $value: 'var(--test)', $extensions: { [EXTENSION_AUTHORED_AS]: 'var(--test)', @@ -502,6 +674,43 @@ describe('css_to_tokens', () => { }, }) }) + + test('dedupes values', () => { + let actual = css_to_tokens(` + .my-design-system { + animation-duration: 100ms; + animation-duration: 0.1s; + animation-duration: .1s; + + animation-duration: 1S; + animation-duration: 1000MS; + } + `) + expect(actual.duration).toEqual({ + 'duration-bdf1': { + $type: 'duration', + $value: { + value: 100, + unit: 'ms' + }, + $extensions: { + [EXTENSION_AUTHORED_AS]: '100ms', + [EXTENSION_USAGE_COUNT]: 3, + } + }, + 'duration-17005f': { + $type: 'duration', + $value: { + value: 1000, + unit: 'ms' + }, + $extensions: { + [EXTENSION_AUTHORED_AS]: '1S', + [EXTENSION_USAGE_COUNT]: 2, + } + }, + }) + }) }) describe('easing', () => { diff --git a/src/index.ts b/src/index.ts index ba928ab..d6595e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -93,25 +93,37 @@ export function analysis_to_tokens(analysis: CssAnalysis): Tokens { for (let [group, group_colors] of color_groups) { for (let color of group_colors) { let color_token = color_to_token(color) + let count = get_count(unique[color]!) if (color_token !== null) { - let name = `${color_dict.get(group)}-${hash(color)}` + let { colorSpace, components, alpha } = color_token + let name = `${color_dict.get(group)}-${hash([colorSpace, ...components, alpha].join(''))}` let items_per_context = analysis.values.colors.itemsPerContext as ItemsPerContext - let properties = Object.entries(items_per_context).reduce((acc, [property, collection]) => { - if (color in collection.unique || (collection.uniqueWithLocations && color in collection.uniqueWithLocations)) { - acc.push(property) + let new_properties = Object.entries(items_per_context).reduce((acc, [property, collection]) => { + if (color in collection.unique) { + return acc.add(property) + } + if (collection.uniqueWithLocations && color in collection.uniqueWithLocations) { + return acc.add(property) } return acc - }, [] as Array) + }, new Set() as Set) - colors[name] = { - $type: 'color', - $value: color_token, - $extensions: { - [EXTENSION_AUTHORED_AS]: color, - [EXTENSION_USAGE_COUNT]: get_count(unique[color]!), - [EXTENSION_CSS_PROPERTIES]: properties, + if (colors[name]) { + let old_properties = colors[name].$extensions[EXTENSION_CSS_PROPERTIES] + colors[name].$extensions[EXTENSION_CSS_PROPERTIES] = Array.from(new Set(old_properties).union(new_properties)) + colors[name].$extensions[EXTENSION_USAGE_COUNT] += count + } + else { + colors[name] = { + $type: 'color', + $value: color_token, + $extensions: { + [EXTENSION_AUTHORED_AS]: color, + [EXTENSION_USAGE_COUNT]: count, + [EXTENSION_CSS_PROPERTIES]: Array.from(new_properties), + } } } } @@ -124,7 +136,6 @@ export function analysis_to_tokens(analysis: CssAnalysis): Tokens { let unique = get_unique(analysis.values.fontSizes) for (let font_size in unique) { - let name = `fontSize-${hash(font_size)}` let parsed = parse_length(font_size) let extensions = { [EXTENSION_AUTHORED_AS]: font_size, @@ -132,15 +143,23 @@ export function analysis_to_tokens(analysis: CssAnalysis): Tokens { } if (parsed === null) { + let name = `fontSize-${hash(font_size)}` font_sizes[name] = { $value: font_size, $extensions: extensions, } - } else { - font_sizes[name] = { - $type: 'dimension', - $value: parsed, - $extensions: extensions, + } + else { + let name = `fontSize-${hash(parsed.value.toString() + parsed.unit)}` + if (font_sizes[name]) { + font_sizes[name].$extensions[EXTENSION_USAGE_COUNT] += extensions[EXTENSION_USAGE_COUNT] + } + else { + font_sizes[name] = { + $type: 'dimension', + $value: parsed, + $extensions: extensions, + } } } } @@ -169,7 +188,6 @@ export function analysis_to_tokens(analysis: CssAnalysis): Tokens { let unique = get_unique(analysis.values.lineHeights) for (let line_height in unique) { - let name = `lineHeight-${hash(line_height)}` let parsed = destructure_line_height(line_height) let extensions = { [EXTENSION_AUTHORED_AS]: line_height, @@ -177,24 +195,41 @@ export function analysis_to_tokens(analysis: CssAnalysis): Tokens { } if (parsed === null) { + let name = `lineHeight-${hash(line_height)}` line_heights[name] = { $value: line_height, $extensions: extensions, } - } else if (typeof parsed === 'number') { - line_heights[name] = { - $type: 'number', - $value: parsed, - $extensions: extensions, + } + else if (typeof parsed === 'number') { + let name = `lineHeight-${hash(parsed)}` + if (line_heights[name]) { + line_heights[name].$extensions[EXTENSION_USAGE_COUNT] += extensions[EXTENSION_USAGE_COUNT] } - } else if (typeof parsed === 'object') { - if (parsed.unit === 'px' || parsed.unit === 'rem') { + else { line_heights[name] = { - $type: 'dimension', + $type: 'number', $value: parsed, $extensions: extensions, } - } else { + } + } + else if (typeof parsed === 'object') { + if (parsed.unit === 'px' || parsed.unit === 'rem') { + let name = `lineHeight-${hash(parsed.value.toString() + parsed.unit)}` + if (line_heights[name]) { + line_heights[name].$extensions[EXTENSION_USAGE_COUNT] += extensions[EXTENSION_USAGE_COUNT] + } + else { + line_heights[name] = { + $type: 'dimension', + $value: parsed, + $extensions: extensions, + } + } + } + else { + let name = `lineHeight-${hash(line_height)}` line_heights[name] = { $value: line_height, $extensions: extensions, @@ -269,23 +304,29 @@ export function analysis_to_tokens(analysis: CssAnalysis): Tokens { for (let duration in unique) { let parsed = convert_duration(duration) let is_valid = parsed < Number.MAX_SAFE_INTEGER - 1 - let name = hash(parsed.toString()) let extensions = { [EXTENSION_AUTHORED_AS]: duration, [EXTENSION_USAGE_COUNT]: get_count(unique[duration]!), } if (is_valid) { - durations[`duration-${name}`] = { - $type: 'duration', - $value: { - value: parsed, - unit: 'ms' - }, - $extensions: extensions, + let name = `duration-${hash(parsed.toString())}` + if (durations[name]) { + durations[name].$extensions[EXTENSION_USAGE_COUNT] += extensions[EXTENSION_USAGE_COUNT] + } + else { + durations[name] = { + $type: 'duration', + $value: { + value: parsed, + unit: 'ms' + }, + $extensions: extensions, + } } } else { - durations[`duration-${name}`] = { + let name = `duration-${hash('invalid' + parsed.toString())}` + durations[name] = { $value: duration, $extensions: extensions, } diff --git a/src/parse-length.test.ts b/src/parse-length.test.ts index 681fcd0..57eb91f 100644 --- a/src/parse-length.test.ts +++ b/src/parse-length.test.ts @@ -26,6 +26,12 @@ test('absolute size keywords', () => { expect.soft(parse_length('xxx-large')).toEqual({ value: 3, unit: 'rem' }) }) +test('unitless 0', () => { + expect.soft(parse_length('0')).toEqual({ value: 0, unit: 'px' }) + expect.soft(parse_length('0.0')).toEqual({ value: 0, unit: 'px' }) + expect.soft(parse_length('+0')).toEqual({ value: 0, unit: 'px' }) +}) + test('invalid values', () => { expect.soft(parse_length('')).toBeNull() expect.soft(parse_length('1')).toBeNull() diff --git a/src/parse-length.ts b/src/parse-length.ts index ef40c26..1431f0c 100644 --- a/src/parse-length.ts +++ b/src/parse-length.ts @@ -32,10 +32,19 @@ export function parse_length(value: string): DesignTokenLength | null { switch (maybe_length.type) { case 'Dimension': { - if (maybe_length.unit === 'px' || maybe_length.unit === 'rem') { + let unit = maybe_length.unit.toLowerCase() + if (unit === 'px' || unit === 'rem') { + let value = Number(maybe_length.value) + // Always return `0px`, `0`, or `0rem` as `0px` + if (value === 0) { + return { + value: 0, + unit: 'px', + } + } return { - value: Number(maybe_length.value), - unit: maybe_length.unit + value: value, + unit, } } break @@ -51,6 +60,16 @@ export function parse_length(value: string): DesignTokenLength | null { unit: 'rem' } } + break + } + case 'Number': { + if (Number(maybe_length.value) === 0) { + return { + value: 0, + unit: 'px', + } + } + break } } diff --git a/tsconfig.json b/tsconfig.json index 59e2b2c..b7c8dd9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { // Base options: "skipLibCheck": true, - "target": "es2022", + "target": "esnext", "verbatimModuleSyntax": true, "allowJs": true, "checkJs": true, @@ -15,11 +15,8 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "noEmit": true, - // Code runs in the DOM "lib": [ - "ES2022", - "DOM", - "DOM.Iterable" + "esnext", ], }, "include": [