From 9a06ccd022bedc6b9585d55c03d94ad14b217067 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 13 Dec 2022 17:12:56 -0800 Subject: [PATCH 01/14] Fetch matching feature branches (#169) This also stops running shell commands silently to help diagnose errors, and ensures that the embedded protocol's VERSION file is accessible even when installing from a path. --- .github/workflows/ci.yml | 12 ++++++---- package.json | 3 ++- tool/utils.ts | 49 ++++++++++++++++++++-------------------- 3 files changed, 35 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f07c33b..e85f2cfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,8 @@ jobs: with: node-version: ${{ matrix.node-version }} check-latest: true + - uses: frenck/action-setup-yq@v1 + with: {version: v4.30.5} # frenck/action-setup-yq#35 - uses: arduino/setup-protoc@v1 with: version: ${{ env.PROTOC_VERSION }} @@ -84,8 +86,8 @@ jobs: - name: Link the embedded compiler to Dart Sass run: | if [[ -d dart-sass ]]; then - echo "dependency_overrides: {sass: {path: ../dart-sass}}" \ - >> dart-sass-embedded/pubspec.yaml + yq -i '.dependency_overrides.sass = {"path": "../dart-sass"}' \ + dart-sass-embedded/pubspec.yaml fi - name: Check out the JS API definition @@ -126,6 +128,8 @@ jobs: with: {sdk: stable} - uses: actions/setup-node@v2 with: {node-version: "${{ matrix.node_version }}"} + - uses: frenck/action-setup-yq@v1 + with: {version: v4.30.5} # frenck/action-setup-yq#35 - uses: arduino/setup-protoc@v1 with: version: ${{ env.PROTOC_VERSION }} @@ -146,8 +150,8 @@ jobs: - name: Link the embedded compiler to Dart Sass run: | if [[ -d dart-sass ]]; then - echo "dependency_overrides: {sass: {path: ../dart-sass}}" \ - >> dart-sass-embedded/pubspec.yaml + yq -i '.dependency_overrides.sass = {"path": "../dart-sass"}' \ + dart-sass-embedded/pubspec.yaml fi - name: Check out the JS API definition diff --git a/package.json b/package.json index d4b30c93..b865bbd4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sass-embedded", "version": "1.56.2", - "protocol-version": "1.1.0", + "protocol-version": "1.2.0", "compiler-version": "1.56.2", "description": "Node.js library that communicates with Embedded Dart Sass using the Embedded Sass protocol", "repository": "sass/embedded-host-node", @@ -62,6 +62,7 @@ "npm-run-all": "^4.1.5", "protoc": "1.0.4", "shelljs": "^0.8.4", + "simple-git": "^3.15.1", "source-map-js": "^0.6.1", "tar": "^6.0.5", "ts-jest": "^27.0.5", diff --git a/tool/utils.ts b/tool/utils.ts index 7f27de42..f1b920a9 100644 --- a/tool/utils.ts +++ b/tool/utils.ts @@ -8,6 +8,7 @@ import fetch from 'node-fetch'; import * as p from 'path'; import * as yaml from 'yaml'; import * as shell from 'shelljs'; +import {simpleGit} from 'simple-git'; import {extract as extractTar} from 'tar'; import * as pkg from '../package.json'; @@ -85,7 +86,7 @@ export async function getEmbeddedProtocol( ): Promise { const repo = 'embedded-protocol'; - options ??= defaultVersionOption('protocol-version'); + options ??= await defaultVersionOption('protocol-version'); if ('version' in options) { const version = options?.version; await downloadRelease({ @@ -108,7 +109,11 @@ export async function getEmbeddedProtocol( const source = options && 'path' in options ? options.path : p.join(BUILD_PATH, repo); buildEmbeddedProtocol(source); - await link('build/embedded-protocol', p.join(outPath, repo)); + + // Make the VERSION consistently accessible for the dependency test and any + // curious users. + await link(p.join(source, 'VERSION'), 'build/embedded-protocol-out/VERSION'); + await link('build/embedded-protocol-out', p.join(outPath, repo)); } /** @@ -136,7 +141,7 @@ export async function getDartSassEmbedded( } ): Promise { const repo = 'dart-sass-embedded'; - options ??= defaultVersionOption('compiler-version'); + options ??= await defaultVersionOption('compiler-version'); await checkForMusl(); @@ -287,8 +292,7 @@ function fetchRepo(options: { `git clone \ --depth=1 \ https://github.com/sass/${options.repo} \ - ${p.join(options.outPath, options.repo)}`, - {silent: true} + ${p.join(options.outPath, options.repo)}` ); } @@ -297,10 +301,7 @@ function fetchRepo(options: { console.log(`Fetching ${version} for ${options.repo}.`); shell.exec( `git fetch --depth=1 origin ${options.ref} && git reset --hard FETCH_HEAD`, - { - silent: true, - cwd: p.join(options.outPath, options.repo), - } + {cwd: p.join(options.outPath, options.repo)} ); } @@ -322,38 +323,38 @@ function buildEmbeddedProtocol(repoPath: string): void { process.platform === 'win32' ? '%CD%/node_modules/.bin/protoc-gen-ts.cmd' : 'node_modules/.bin/protoc-gen-ts'; - mkdirSync('build/embedded-protocol', {recursive: true}); + mkdirSync('build/embedded-protocol-out', {recursive: true}); shell.exec( `${protocPath} \ --plugin="protoc-gen-ts=${pluginPath}" \ - --js_out="import_style=commonjs,binary:build/embedded-protocol" \ - --ts_out="build/embedded-protocol" \ + --js_out="import_style=commonjs,binary:build/embedded-protocol-out" \ + --ts_out="build/embedded-protocol-out" \ --proto_path="${repoPath}" \ - ${proto}`, - {silent: true} + ${proto}` ); } // Builds the Embedded Dart Sass executable from the source at `repoPath`. function buildDartSassEmbedded(repoPath: string): void { console.log('Downloading dart-sass-embedded dependencies.'); - shell.exec('dart pub upgrade', { - cwd: repoPath, - silent: true, - }); + shell.exec('dart pub upgrade', {cwd: repoPath}); console.log('Building dart-sass-embedded executable.'); - shell.exec('dart run grinder protobuf pkg-standalone-dev', { - cwd: repoPath, - silent: true, - }); + shell.exec('dart run grinder protobuf pkg-standalone-dev', {cwd: repoPath}); } // Given the name of a field in `package.json`, returns the default version // option described by that field. -function defaultVersionOption( +async function defaultVersionOption( pkgField: keyof typeof pkg -): {version: string} | {ref: string} { +): Promise<{version: string} | {ref: string}> { + // If we're on a feature branch, fetch the matching feature branch from the + // compiler. + if (pkgField === 'compiler-version') { + const branch = (await simpleGit().branch()).current; + if (branch.startsWith('feature.')) return {ref: branch}; + } + const version = pkg[pkgField] as string; return version.endsWith('-dev') ? {ref: 'main'} : {version}; } From d283b3c742ca7aac80d2381631c8b490b025ba7f Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 13 Dec 2022 17:24:12 -0800 Subject: [PATCH 02/14] Run tests on pushes to feature branches (#171) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e85f2cfb..3da15848 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ env: on: push: - branches: [main] + branches: [main, feature.*] tags: ['**'] pull_request: From 058353e70cf3d3b6db58c0f8ada9d66b4ccb5e46 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 24 Jan 2023 14:45:47 -0800 Subject: [PATCH 03/14] Poke CI From 69c4e5eeed96b24f7febd0818bfaf365b60bcbfc Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 24 Jan 2023 16:01:52 -0800 Subject: [PATCH 04/14] Poke CI From 435a1815a18c929ce7fc21def6bca665e7840fbe Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 26 Jan 2023 15:45:53 -0800 Subject: [PATCH 05/14] Make compiler-version -dev --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bb47fbb2..ca41601e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "sass-embedded", "version": "1.57.1", "protocol-version": "1.2.0", - "compiler-version": "1.58.0", + "compiler-version": "1.58.0-dev", "description": "Node.js library that communicates with Embedded Dart Sass using the Embedded Sass protocol", "repository": "sass/embedded-host-node", "author": "Google Inc.", From 8c82e53c80a15feb8469de5f4a8ee26ae2c28f0a Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Fri, 17 Nov 2023 18:27:55 -0500 Subject: [PATCH 06/14] [Color 4] Add support for CSS Color Level 4. (#259) --- .github/workflows/ci.yml | 4 +- lib/src/protofier.ts | 111 ++- lib/src/value/color.ts | 1378 ++++++++++++++++++++++++++++++-------- package.json | 5 +- tsconfig.json | 1 - 5 files changed, 1174 insertions(+), 325 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70129465..fc25b123 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: strategy: matrix: os: [ubuntu, macos, windows] - node-version: [18.x, 16.x, 14.x] # If changing this, also change env.DEFAULT_NODE_VERSION + node-version: [18.x, 16.x] # If changing this, also change env.DEFAULT_NODE_VERSION fail-fast: false steps: @@ -89,8 +89,6 @@ jobs: # Include LTS versions on Ubuntu - os: ubuntu node_version: 16 - - os: ubuntu - node_version: 14 steps: - uses: actions/checkout@v2 diff --git a/lib/src/protofier.ts b/lib/src/protofier.ts index e2d7f951..8b3acd7c 100644 --- a/lib/src/protofier.ts +++ b/lib/src/protofier.ts @@ -8,7 +8,7 @@ import * as proto from './vendor/embedded_sass_pb'; import * as utils from './utils'; import {FunctionRegistry} from './function-registry'; import {SassArgumentList} from './value/argument-list'; -import {SassColor} from './value/color'; +import {SassColor, KnownColorSpace} from './value/color'; import {SassFunction} from './value/function'; import {SassList, ListSeparator} from './value/list'; import {SassMap} from './value/map'; @@ -66,21 +66,14 @@ export class Protofier { } else if (value instanceof SassNumber) { result.value = {case: 'number', value: this.protofyNumber(value)}; } else if (value instanceof SassColor) { - if (value.hasCalculatedHsl) { - const color = new proto.Value_HslColor(); - color.hue = value.hue; - color.saturation = value.saturation; - color.lightness = value.lightness; - color.alpha = value.alpha; - result.value = {case: 'hslColor', value: color}; - } else { - const color = new proto.Value_RgbColor(); - color.red = value.red; - color.green = value.green; - color.blue = value.blue; - color.alpha = value.alpha; - result.value = {case: 'rgbColor', value: color}; - } + const color = new proto.Value_Color(); + const channels = value.channels; + color.channel1 = channels.get(0) as number; + color.channel2 = channels.get(1) as number; + color.channel3 = channels.get(2) as number; + color.alpha = value.alpha; + color.space = value.space; + result.value = {case: 'color', value: color}; } else if (value instanceof SassList) { const list = new proto.Value_List(); list.separator = this.protofySeparator(value.separator); @@ -242,24 +235,76 @@ export class Protofier { return this.deprotofyNumber(value.value.value); } - case 'rgbColor': { - const color = value.value.value; - return new SassColor({ - red: color.red, - green: color.green, - blue: color.blue, - alpha: color.alpha, - }); - } - - case 'hslColor': { + case 'color': { const color = value.value.value; - return new SassColor({ - hue: color.hue, - saturation: color.saturation, - lightness: color.lightness, - alpha: color.alpha, - }); + switch (color.space.toLowerCase()) { + case 'rgb': + case 'srgb': + case 'srgb-linear': + case 'display-p3': + case 'a98-rgb': + case 'prophoto-rgb': + case 'rec2020': + return new SassColor({ + red: color.channel1, + green: color.channel2, + blue: color.channel3, + alpha: color.alpha, + space: color.space as KnownColorSpace, + }); + + case 'hsl': + return new SassColor({ + hue: color.channel1, + saturation: color.channel2, + lightness: color.channel3, + alpha: color.alpha, + space: 'hsl', + }); + + case 'hwb': + return new SassColor({ + hue: color.channel1, + whiteness: color.channel2, + blackness: color.channel3, + alpha: color.alpha, + space: 'hwb', + }); + + case 'lab': + case 'oklab': + return new SassColor({ + lightness: color.channel1, + a: color.channel2, + b: color.channel3, + alpha: color.alpha, + space: color.space as KnownColorSpace, + }); + + case 'lch': + case 'oklch': + return new SassColor({ + lightness: color.channel1, + chroma: color.channel2, + hue: color.channel3, + alpha: color.alpha, + space: color.space as KnownColorSpace, + }); + + case 'xyz': + case 'xyz-d65': + case 'xyz-d50': + return new SassColor({ + x: color.channel1, + y: color.channel2, + z: color.channel3, + alpha: color.alpha, + space: color.space as KnownColorSpace, + }); + + default: + throw utils.compilerError(`Unknown color space "${color.space}".`); + } } case 'list': { diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index b2aaad1f..b8bcaa36 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -3,371 +3,1177 @@ // https://opensource.org/licenses/MIT. import {Value} from './index'; +import {valueError} from '../utils'; import { fuzzyAssertInRange, fuzzyEquals, + fuzzyHashCode, fuzzyRound, positiveMod, } from './utils'; -import {hash} from 'immutable'; +import {List, hash} from 'immutable'; +import Color from 'colorjs.io'; -interface RgbColor { - red: number; - green: number; - blue: number; - alpha?: number; +/** The HSL color space name. */ +type ColorSpaceHsl = 'hsl'; + +/** The HSL color space channel names. */ +type ChannelNameHsl = 'hue' | 'saturation' | 'lightness' | 'alpha'; + +/** The HWB color space name. */ +type ColorSpaceHwb = 'hwb'; + +/** The HWB color space channel names. */ +type ChannelNameHwb = 'hue' | 'whiteness' | 'blackness' | 'alpha'; + +/** The Lab / Oklab color space names. */ +type ColorSpaceLab = 'lab' | 'oklab'; + +/** The Lab / Oklab color space channel names. */ +type ChannelNameLab = 'lightness' | 'a' | 'b' | 'alpha'; + +/** The LCH / Oklch color space names. */ +type ColorSpaceLch = 'lch' | 'oklch'; + +/** The LCH / Oklch color space channel names. */ +type ChannelNameLch = 'lightness' | 'chroma' | 'hue' | 'alpha'; + +/** Names of color spaces with RGB channels. */ +type ColorSpaceRgb = + | 'a98-rgb' + | 'display-p3' + | 'prophoto-rgb' + | 'rec2020' + | 'rgb' + | 'srgb' + | 'srgb-linear'; + +/** RGB channel names. */ +type ChannelNameRgb = 'red' | 'green' | 'blue' | 'alpha'; + +/** Names of color spaces with XYZ channels. */ +type ColorSpaceXyz = 'xyz' | 'xyz-d50' | 'xyz-d65'; + +/** XYZ channel names. */ +type ChannelNameXyz = 'x' | 'y' | 'z' | 'alpha'; + +/** All supported color space channel names. */ +type ChannelName = + | ChannelNameHsl + | ChannelNameHwb + | ChannelNameLab + | ChannelNameLch + | ChannelNameRgb + | ChannelNameXyz; + +/** All supported color space names. */ +export type KnownColorSpace = + | ColorSpaceHsl + | ColorSpaceHwb + | ColorSpaceLab + | ColorSpaceLch + | ColorSpaceRgb + | ColorSpaceXyz; + +/** Polar color space names (HSL, HWB, LCH, and Oklch spaces). */ +type PolarColorSpace = ColorSpaceHsl | ColorSpaceHwb | ColorSpaceLch; + +/** + * Methods by which two hues are adjusted when interpolating between polar + * colors. + */ +type HueInterpolationMethod = + | 'decreasing' + | 'increasing' + | 'longer' + | 'shorter'; + +/** Options for specifying any channel value. */ +type ChannelOptions = { + [key in ChannelName]?: number | null; +}; + +/** Constructor options for specifying space and/or channel values. */ +type ConstructorOptions = ChannelOptions & {space?: KnownColorSpace}; + +/** Constructor options for passing in existing ColorJS object and space. */ +type OptionsWithColor = {color: Color; space: KnownColorSpace}; + +/** Legacy determination of color space by channel name. */ +function getColorSpace(options: ChannelOptions): KnownColorSpace { + if (typeof options.red === 'number') return 'rgb'; + if (typeof options.saturation === 'number') return 'hsl'; + if (typeof options.whiteness === 'number') return 'hwb'; + throw valueError('No color space found'); +} + +/** + * Convert from the ColorJS representation of a missing component (`NaN`) to + * `null`. + */ +function NaNtoNull(val: number): number | null { + return Number.isNaN(val) ? null : val; +} + +/** + * Convert from the ColorJS representation of a missing component (`NaN`) to + * `0`. + */ +function NaNtoZero(val: number): number { + return Number.isNaN(val) ? 0 : val; +} + +/** + * Assert that `val` is either `NaN` or within `min` and `max`. Otherwise, + * throw an error. + */ +function assertClamped( + val: number, + min: number, + max: number, + name: string +): number { + return Number.isNaN(val) ? val : fuzzyAssertInRange(val, min, max, name); +} + +/** Convert from sRGB (0-1) to RGB (0-255) units. */ +function coordToRgb(val: number): number { + return val * 255; +} + +/** Normalize `hue` values to be within the range `[0, 360)`. */ +function normalizeHue(val: number): number { + return positiveMod(val, 360); +} + +/** + * Normalize discrepancies between Sass color spaces and ColorJS color space + * ids, converting Sass values to ColorJS values. + */ +function encodeSpaceForColorJs(space?: KnownColorSpace): string | undefined { + switch (space) { + case 'rgb': + return 'srgb'; + case 'a98-rgb': + return 'a98rgb'; + case 'display-p3': + return 'p3'; + case 'prophoto-rgb': + return 'prophoto'; + } + return space; } -interface HslColor { - hue: number; - saturation: number; - lightness: number; - alpha?: number; +/** + * Normalize discrepancies between Sass color spaces and ColorJS color space + * ids, converting ColorJS values to Sass values. + */ +function decodeSpaceFromColorJs(space: string, isRgb = false): KnownColorSpace { + switch (space) { + case 'srgb': + return isRgb ? 'rgb' : space; + case 'xyz-d65': + return 'xyz'; + case 'a98rgb': + return 'a98-rgb'; + case 'p3': + return 'display-p3'; + case 'prophoto': + return 'prophoto-rgb'; + } + return space as KnownColorSpace; +} + +/** + * Normalize discrepancies between Sass channel names and ColorJS channel ids, + * converting Sass values to ColorJS values. + * + * @TODO Waiting on a new release of ColorJS that allows Lab spaces to accept + * `lightness` instead of only `l` and not as a channel name. + * Fixed in: https://github.com/LeaVerou/color.js/pull/348 + */ +function encodeChannelForColorJs(channel: ChannelName): string { + if (channel === 'lightness') return 'l'; + return channel; +} + +/** + * Implement our own check of channel name validity for a given space, because + * ColorJS allows e.g. `b` for any of `blue`, `blackness`, or `b` channels. + */ +function validateChannelInSpace( + channel: ChannelName, + space: KnownColorSpace +): void { + if (channel === 'alpha') return; + let valid = false; + switch (space) { + case 'rgb': + case 'srgb': + case 'srgb-linear': + case 'display-p3': + case 'a98-rgb': + case 'prophoto-rgb': + case 'rec2020': + valid = ['red', 'green', 'blue'].includes(channel); + break; + case 'hsl': + valid = ['hue', 'saturation', 'lightness'].includes(channel); + break; + case 'hwb': + valid = ['hue', 'whiteness', 'blackness'].includes(channel); + break; + case 'lab': + case 'oklab': + valid = ['lightness', 'a', 'b'].includes(channel); + break; + case 'lch': + case 'oklch': + valid = ['lightness', 'chroma', 'hue'].includes(channel); + break; + case 'xyz': + case 'xyz-d65': + case 'xyz-d50': + valid = ['x', 'y', 'z'].includes(channel); + break; + } + if (!valid) { + throw valueError( + `Unknown channel name "${channel}" for color space "${space}".` + ); + } +} + +/** Determine whether the given space is a polar color space. */ +function isPolarColorSpace(space: KnownColorSpace): space is PolarColorSpace { + switch (space) { + case 'hsl': + case 'hwb': + case 'lch': + case 'oklch': + return true; + default: + return false; + } +} + +/** + * Convert from ColorJS coordinates (which use `NaN` for missing components, and + * a range of `0-1` for `rgb` channel values) to Sass Color coordinates (which + * use `null` for missing components, and a range of `0-255` for `rgb` channel + * values). + */ +function decodeCoordsFromColorJs( + coords: [number, number, number], // ColorJS coordinates + isRgb = false // Whether this color is in the `rgb` color space +): [number | null, number | null, number | null] { + let newCoords = coords; + // If this color is in the `rgb` space, convert channel values to `0-255` + if (isRgb) newCoords = newCoords.map(coordToRgb) as [number, number, number]; + // Convert `NaN` values to `null` + return newCoords.map(NaNtoNull) as [ + number | null, + number | null, + number | null, + ]; +} + +/** Returns `true` if `val` is a `number` or `null`. */ +function isNumberOrNull(val: undefined | null | number): val is number | null { + return val === null || typeof val === 'number'; +} + +/** + * Emit deprecation warnings when legacy color spaces set `alpha` or channel + * values to `null` without explicitly setting the `space`. + */ +function checkChangeDeprecations( + options: { + [key in ChannelName]?: number | null; + }, + channels: ChannelName[] +) { + if (options.alpha === null) emitNullAlphaDeprecation(); + for (const channel of channels) { + if (options[channel] === null) emitColor4ApiChangeNullDeprecation(channel); + } +} + +/** Warn users about legacy color channel getters. */ +function emitColor4ApiGetterDeprecation(name: string) { + console.warn( + 'Deprecation [color-4-api]: ' + + `\`${name}\` is deprecated, use \`channel\` instead.` + + '\n' + + 'More info: https://sass-lang.com/d/color-4-api' + ); +} + +/** + * Warn users about changing channels not in the current color space without + * explicitly setting `space`. + */ +function emitColor4ApiChangeSpaceDeprecation() { + console.warn( + 'Deprecation [color-4-api]: ' + + "Changing a channel not in this color's space without explicitly " + + 'specifying the `space` option is deprecated.' + + '\n' + + 'More info: https://sass-lang.com/d/color-4-api' + ); +} + +/** Warn users about `null` channel values without setting `space`. */ +function emitColor4ApiChangeNullDeprecation(channel: string) { + console.warn( + 'Deprecation [color-4-api]: ' + + `Passing \`${channel}: null\` without setting \`space\` is deprecated.` + + '\n' + + 'More info: https://sass-lang.com/d/color-4-api' + ); +} + +/** Warn users about null-alpha deprecation. */ +function emitNullAlphaDeprecation() { + console.warn( + 'Deprecation [null-alpha]: ' + + 'Passing `alpha: null` without setting `space` is deprecated.' + + '\n' + + 'More info: https://sass-lang.com/d/null-alpha' + ); } -interface HwbColor { - hue: number; - whiteness: number; - blackness: number; - alpha?: number; +/** + * Determines whether the options passed to the Constructor include an existing + * ColorJS color object. + */ +function optionsHaveColor( + opts: OptionsWithColor | ConstructorOptions +): opts is OptionsWithColor { + return (opts as OptionsWithColor).color instanceof Color; } /** A SassScript color. */ export class SassColor extends Value { - private redInternal?: number; - private greenInternal?: number; - private blueInternal?: number; - private hueInternal?: number; - private saturationInternal?: number; - private lightnessInternal?: number; - private readonly alphaInternal: number; - - constructor(color: RgbColor); - constructor(color: HslColor); - constructor(color: HwbColor); - constructor(color: RgbColor | HslColor | HwbColor) { + // ColorJS color object + private readonly color: Color; + + // Boolean indicating whether this color is in RGB format + // + // ColorJS treats `rgb` as an output format of the `srgb` color space, while + // Sass treats it as its own color space. By internally tracking whether this + // color is `rgb` or not, we can use `srgb` consistently for ColorJS while + // still returning expected `rgb` values for Sass users. + private readonly isRgb: boolean = false; + + // Names for the channels of this color + private channel0Id!: ChannelName; + private channel1Id!: ChannelName; + private channel2Id!: ChannelName; + + // Sets channel names based on this color's color space + private setChannelIds(space: KnownColorSpace) { + switch (space) { + case 'rgb': + case 'srgb': + case 'srgb-linear': + case 'display-p3': + case 'a98-rgb': + case 'prophoto-rgb': + case 'rec2020': + this.channel0Id = 'red'; + this.channel1Id = 'green'; + this.channel2Id = 'blue'; + break; + + case 'hsl': + this.channel0Id = 'hue'; + this.channel1Id = 'saturation'; + this.channel2Id = 'lightness'; + break; + + case 'hwb': + this.channel0Id = 'hue'; + this.channel1Id = 'whiteness'; + this.channel2Id = 'blackness'; + break; + + case 'lab': + case 'oklab': + this.channel0Id = 'lightness'; + this.channel1Id = 'a'; + this.channel2Id = 'b'; + break; + + case 'lch': + case 'oklch': + this.channel0Id = 'lightness'; + this.channel1Id = 'chroma'; + this.channel2Id = 'hue'; + break; + + case 'xyz': + case 'xyz-d65': + case 'xyz-d50': + this.channel0Id = 'x'; + this.channel1Id = 'y'; + this.channel2Id = 'z'; + break; + } + } + + constructor(options: OptionsWithColor); + constructor(options: ConstructorOptions); + constructor(optionsMaybeWithColor: OptionsWithColor | ConstructorOptions) { super(); - if ('red' in color) { - this.redInternal = fuzzyAssertInRange( - Math.round(color.red), - 0, - 255, - 'red' - ); - this.greenInternal = fuzzyAssertInRange( - Math.round(color.green), - 0, - 255, - 'green' - ); - this.blueInternal = fuzzyAssertInRange( - Math.round(color.blue), - 0, - 255, - 'blue' - ); - } else if ('saturation' in color) { - this.hueInternal = positiveMod(color.hue, 360); - this.saturationInternal = fuzzyAssertInRange( - color.saturation, - 0, - 100, - 'saturation' - ); - this.lightnessInternal = fuzzyAssertInRange( - color.lightness, - 0, - 100, - 'lightness' - ); + let options: ConstructorOptions; + + // Use existing ColorJS color object from options for the new SassColor + if (optionsHaveColor(optionsMaybeWithColor)) { + const {color, space} = optionsMaybeWithColor; + if (space === 'rgb') this.isRgb = true; + this.setChannelIds(space); + this.color = color; + return; + } else { + options = optionsMaybeWithColor; + } + + const space = options.space ?? getColorSpace(options); + this.setChannelIds(space); + if (space === 'rgb') this.isRgb = true; + let alpha: number; + if (options.alpha === null) { + if (!options.space) emitNullAlphaDeprecation(); + alpha = NaN; + } else if (options.alpha === undefined) { + alpha = 1; } else { - // From https://www.w3.org/TR/css-color-4/#hwb-to-rgb - const scaledHue = positiveMod(color.hue, 360) / 360; - let scaledWhiteness = - fuzzyAssertInRange(color.whiteness, 0, 100, 'whiteness') / 100; - let scaledBlackness = - fuzzyAssertInRange(color.blackness, 0, 100, 'blackness') / 100; - - const sum = scaledWhiteness + scaledBlackness; - if (sum > 1) { - scaledWhiteness /= sum; - scaledBlackness /= sum; + alpha = fuzzyAssertInRange(options.alpha, 0, 1, 'alpha'); + } + + switch (space) { + case 'rgb': + case 'srgb': { + const red = options.red ?? NaN; + const green = options.green ?? NaN; + const blue = options.blue ?? NaN; + if (this.isRgb) { + this.color = new Color({ + spaceId: encodeSpaceForColorJs(space), + // convert from 0-255 to 0-1 + coords: [red / 255, green / 255, blue / 255], + alpha, + }); + } else { + this.color = new Color({ + spaceId: encodeSpaceForColorJs(space), + coords: [red, green, blue], + alpha, + }); + } + break; } - // Because HWB is (currently) used much less frequently than HSL or RGB, we - // don't cache its values because we expect the memory overhead of doing so - // to outweigh the cost of recalculating it on access. Instead, we eagerly - // convert it to RGB and then convert back if necessary. - this.redInternal = hwbToRgb( - scaledHue + 1 / 3, - scaledWhiteness, - scaledBlackness - ); - this.greenInternal = hwbToRgb( - scaledHue, - scaledWhiteness, - scaledBlackness - ); - this.blueInternal = hwbToRgb( - scaledHue - 1 / 3, - scaledWhiteness, - scaledBlackness - ); + case 'srgb-linear': + case 'display-p3': + case 'a98-rgb': + case 'prophoto-rgb': + case 'rec2020': + this.color = new Color({ + spaceId: encodeSpaceForColorJs(space), + coords: [ + options.red ?? NaN, + options.green ?? NaN, + options.blue ?? NaN, + ], + alpha, + }); + break; + + case 'hsl': { + const hue = normalizeHue(options.hue ?? NaN); + const saturation = options.saturation ?? NaN; + let lightness = options.lightness ?? NaN; + lightness = assertClamped(lightness, 0, 100, 'lightness'); + this.color = new Color({ + spaceId: encodeSpaceForColorJs(space), + coords: [hue, saturation, lightness], + alpha, + }); + break; + } + + case 'hwb': { + const hue = normalizeHue(options.hue ?? NaN); + const whiteness = options.whiteness ?? NaN; + const blackness = options.blackness ?? NaN; + this.color = new Color({ + spaceId: encodeSpaceForColorJs(space), + coords: [hue, whiteness, blackness], + alpha, + }); + break; + } + + case 'lab': + case 'oklab': { + let lightness = options.lightness ?? NaN; + const a = options.a ?? NaN; + const b = options.b ?? NaN; + const maxLightness = space === 'lab' ? 100 : 1; + lightness = assertClamped(lightness, 0, maxLightness, 'lightness'); + this.color = new Color({ + spaceId: encodeSpaceForColorJs(space), + coords: [lightness, a, b], + alpha, + }); + break; + } + + case 'lch': + case 'oklch': { + let lightness = options.lightness ?? NaN; + const chroma = options.chroma ?? NaN; + const hue = normalizeHue(options.hue ?? NaN); + const maxLightness = space === 'lch' ? 100 : 1; + lightness = assertClamped(lightness, 0, maxLightness, 'lightness'); + this.color = new Color({ + spaceId: encodeSpaceForColorJs(space), + coords: [lightness, chroma, hue], + alpha, + }); + break; + } + + case 'xyz': + case 'xyz-d65': + case 'xyz-d50': + this.color = new Color({ + spaceId: encodeSpaceForColorJs(space), + coords: [options.x ?? NaN, options.y ?? NaN, options.z ?? NaN], + alpha, + }); + break; } - this.alphaInternal = - color.alpha === undefined - ? 1 - : fuzzyAssertInRange(color.alpha, 0, 1, 'alpha'); + // @TODO Waiting on new release of ColorJS that includes allowing `alpha` + // to be `NaN` on initial construction. + // Fixed in: https://github.com/LeaVerou/color.js/commit/08b39c180565ae61408ad737d91bd71a1f79d3df + if (Number.isNaN(alpha)) { + this.color.alpha = NaN; + } } - /** `this`'s red channel. */ - get red(): number { - if (this.redInternal === undefined) { - this.hslToRgb(); + /** This color's alpha channel, between `0` and `1`. */ + get alpha(): number { + return NaNtoZero(this.color.alpha); + } + + /** The name of this color's color space. */ + get space(): KnownColorSpace { + return decodeSpaceFromColorJs(this.color.spaceId, this.isRgb); + } + + /** + * A boolean indicating whether this color is in a legacy color space (`rgb`, + * `hsl`, or `hwb`). + */ + get isLegacy(): boolean { + return ['rgb', 'hsl', 'hwb'].includes(this.space); + } + + /** + * A list of this color's channel values (excluding alpha), with [missing + * channels] converted to `null`. + * + * [missing channels]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + */ + get channelsOrNull(): List { + let coords = this.color.coords; + if (this.space === 'rgb') { + coords = coords.map(coordToRgb) as [number, number, number]; } - return this.redInternal!; + return List(coords.map(NaNtoNull)); } - /** `this`'s blue channel. */ - get blue(): number { - if (this.blueInternal === undefined) { - this.hslToRgb(); + /** + * A list of this color's channel values (excluding alpha), with [missing + * channels] converted to `0`. + * + * [missing channels]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + */ + get channels(): List { + let coords = this.color.coords; + if (this.space === 'rgb') { + coords = coords.map(coordToRgb) as [number, number, number]; } - return this.blueInternal!; + return List(coords.map(NaNtoZero)); } - /** `this`'s green channel. */ + /** + * This color's red channel in the RGB color space, between `0` and `255`. + * + * @deprecated Use {@link channel} instead. + */ + get red(): number { + emitColor4ApiGetterDeprecation('red'); + const val = NaNtoZero(coordToRgb(this.color.srgb.red)); + return fuzzyRound(val); + } + + /** + * This color's green channel in the RGB color space, between `0` and `255`. + * + * @deprecated Use {@link channel} instead. + */ get green(): number { - if (this.greenInternal === undefined) { - this.hslToRgb(); - } - return this.greenInternal!; + emitColor4ApiGetterDeprecation('green'); + const val = NaNtoZero(coordToRgb(this.color.srgb.green)); + return fuzzyRound(val); } - /** `this`'s hue value. */ + /** + * This color's blue channel in the RGB color space, between `0` and `255`. + * + * @deprecated Use {@link channel} instead. + */ + get blue(): number { + emitColor4ApiGetterDeprecation('blue'); + const val = NaNtoZero(coordToRgb(this.color.srgb.blue)); + return fuzzyRound(val); + } + + /** + * This color's hue in the HSL color space, between `0` and `360`. + * + * @deprecated Use {@link channel} instead. + */ get hue(): number { - if (this.hueInternal === undefined) { - this.rgbToHsl(); - } - return this.hueInternal!; + emitColor4ApiGetterDeprecation('hue'); + return NaNtoZero(this.color.hsl.hue); } - /** `this`'s saturation value. */ + /** + * This color's saturation in the HSL color space, between `0` and `100`. + * + * @deprecated Use {@link channel} instead. + */ get saturation(): number { - if (this.saturationInternal === undefined) { - this.rgbToHsl(); - } - return this.saturationInternal!; + emitColor4ApiGetterDeprecation('saturation'); + return NaNtoZero(this.color.hsl.saturation); } - /** `this`'s hue value. */ + /** + * This color's lightness in the HSL color space, between `0` and `100`. + * + * @deprecated Use {@link channel} instead. + */ get lightness(): number { - if (this.lightnessInternal === undefined) { - this.rgbToHsl(); - } - return this.lightnessInternal!; + emitColor4ApiGetterDeprecation('lightness'); + return NaNtoZero(this.color.hsl.lightness); } - /** `this`'s whiteness value. */ + /** + * This color's whiteness in the HWB color space, between `0` and `100`. + * + * @deprecated Use {@link channel} instead. + */ get whiteness(): number { - // Because HWB is (currently) used much less frequently than HSL or RGB, we - // don't cache its values because we expect the memory overhead of doing so - // to outweigh the cost of recalculating it on access. - return (Math.min(this.red, this.green, this.blue) / 255) * 100; + emitColor4ApiGetterDeprecation('whiteness'); + return NaNtoZero(this.color.hwb.whiteness); } - /** `this`'s blackness value. */ + /** + * This color's blackness in the HWB color space, between `0` and `100`. + * + * @deprecated Use {@link channel} instead. + */ get blackness(): number { - // Because HWB is (currently) used much less frequently than HSL or RGB, we - // don't cache its values because we expect the memory overhead of doing so - // to outweigh the cost of recalculating it on access. - return 100 - (Math.max(this.red, this.green, this.blue) / 255) * 100; + emitColor4ApiGetterDeprecation('blackness'); + return NaNtoZero(this.color.hwb.blackness); } - /** `this`'s alpha channel. */ - get alpha(): number { - return this.alphaInternal; + assertColor(): SassColor { + return this; } /** - * Whether `this` has already calculated the HSL components for the color. - * - * This is an internal property that's not an official part of Sass's JS API, - * and may be broken at any time. + * Returns a new color that's the result of converting this color to the + * specified `space`. */ - get hasCalculatedHsl(): boolean { - return !!this.hueInternal; + toSpace(space: KnownColorSpace): SassColor { + if (space === this.space) return this; + const color = this.color.to(encodeSpaceForColorJs(space) as string); + return new SassColor({color, space}); } - assertColor(): SassColor { - return this; + /** + * Returns a boolean indicating whether this color is in-gamut (as opposed to + * having one or more of its channels out of bounds) for the specified + * `space`, or its current color space if `space` is not specified. + */ + isInGamut(space?: KnownColorSpace): boolean { + return this.color.inGamut(encodeSpaceForColorJs(space)); } /** - * Returns a copy of `this` with its channels changed to match `color`. + * Returns a copy of this color, modified so it is in-gamut for the specified + * `space`—or the current color space if `space` is not specified—using the + * recommended [CSS Gamut Mapping Algorithm][css-mapping] to map out-of-gamut + * colors into the desired gamut with as little perceptual change as possible. + * + * [css-mapping]: + * https://www.w3.org/TR/css-color-4/#css-gamut-mapping-algorithm */ - change(color: Partial): SassColor; - change(color: Partial): SassColor; - change(color: Partial): SassColor; - change( - color: Partial | Partial | Partial - ): SassColor { - if ('whiteness' in color || 'blackness' in color) { - return new SassColor({ - hue: color.hue ?? this.hue, - whiteness: color.whiteness ?? this.whiteness, - blackness: color.blackness ?? this.blackness, - alpha: color.alpha ?? this.alpha, - }); - } else if ( - 'hue' in color || - 'saturation' in color || - 'lightness' in color - ) { - // Tell TypeScript this isn't a Partial. - const hsl = color as Partial; - return new SassColor({ - hue: hsl.hue ?? this.hue, - saturation: hsl.saturation ?? this.saturation, - lightness: hsl.lightness ?? this.lightness, - alpha: hsl.alpha ?? this.alpha, - }); - } else if ( - 'red' in color || - 'green' in color || - 'blue' in color || - this.redInternal - ) { - const rgb = color as Partial; - return new SassColor({ - red: rgb.red ?? this.red, - green: rgb.green ?? this.green, - blue: rgb.blue ?? this.blue, - alpha: rgb.alpha ?? this.alpha, + toGamut(space?: KnownColorSpace): SassColor { + if (this.isInGamut(space)) return this; + const color = this.color + .clone() + .toGamut({space: encodeSpaceForColorJs(space)}); + return new SassColor({color, space: space ?? this.space}); + } + + /** + * Returns the value of a single specified `channel` of this color (optionally + * after converting this color to the specified `space`), with [missing + * channels] converted to `0`. + * + * [missing channels]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + */ + channel(channel: ChannelName): number; + channel(channel: ChannelNameHsl, options: {space: ColorSpaceHsl}): number; + channel(channel: ChannelNameHwb, options: {space: ColorSpaceHwb}): number; + channel(channel: ChannelNameLab, options: {space: ColorSpaceLab}): number; + channel(channel: ChannelNameLch, options: {space: ColorSpaceLch}): number; + channel(channel: ChannelNameRgb, options: {space: ColorSpaceRgb}): number; + channel(channel: ChannelNameXyz, options: {space: ColorSpaceXyz}): number; + channel(channel: ChannelName, options?: {space: KnownColorSpace}): number { + if (channel === 'alpha') return this.alpha; + let val: number; + const space = options?.space ?? this.space; + validateChannelInSpace(channel, space); + if (options?.space) { + val = this.color.get({ + space: encodeSpaceForColorJs(options.space) as string, + coordId: encodeChannelForColorJs(channel), }); } else { - return new SassColor({ - hue: this.hue, - saturation: this.saturation, - lightness: this.lightness, - alpha: color.alpha ?? this.alpha, + val = this.color.get({ + space: this.color.spaceId, + coordId: encodeChannelForColorJs(channel), }); } + if (space === 'rgb') val = coordToRgb(val); + return NaNtoZero(val); } - equals(other: Value): boolean { - return ( - other instanceof SassColor && - fuzzyEquals(this.red, other.red) && - fuzzyEquals(this.green, other.green) && - fuzzyEquals(this.blue, other.blue) && - fuzzyEquals(this.alpha, other.alpha) + /** + * Returns a boolean indicating whether a given channel value is a [missing + * channel]. + * + * [missing channel]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + */ + isChannelMissing(channel: ChannelName): boolean { + if (channel === 'alpha') return Number.isNaN(this.color.alpha); + validateChannelInSpace(channel, this.space); + return Number.isNaN( + this.color.get({ + space: this.color.spaceId, + coordId: encodeChannelForColorJs(channel), + }) ); } - hashCode(): number { - return hash(this.red ^ this.green ^ this.blue ^ this.alpha); + /** + * Returns a boolean indicating whether a given `channel` is [powerless] in + * this color. This is a special state that's defined for individual color + * spaces, which indicates that a channel's value won't affect how a color is + * displayed. + * + * [powerless]: https://www.w3.org/TR/css-color-4/#powerless + */ + isChannelPowerless( + channel: ChannelNameHsl, + options?: {space: ColorSpaceHsl} + ): boolean; + isChannelPowerless( + channel: ChannelNameHwb, + options?: {space: ColorSpaceHwb} + ): boolean; + isChannelPowerless( + channel: ChannelNameLab, + options?: {space: ColorSpaceLab} + ): boolean; + isChannelPowerless( + channel: ChannelNameLch, + options?: {space: ColorSpaceLch} + ): boolean; + isChannelPowerless( + channel: ChannelNameRgb, + options?: {space: ColorSpaceRgb} + ): boolean; + isChannelPowerless( + channel: ChannelNameXyz, + options?: {space: ColorSpaceXyz} + ): boolean; + isChannelPowerless( + channel: ChannelName, + options?: {space: KnownColorSpace} + ): boolean { + if (channel === 'alpha') return false; + const color = options?.space ? this.toSpace(options.space) : this; + validateChannelInSpace(channel, color.space); + const channels = color.channels.toArray(); + switch (channel) { + case color.channel0Id: + if (color.space === 'hsl') return fuzzyEquals(channels[1], 0); + if (color.space === 'hwb') { + return fuzzyEquals(channels[1] + channels[2], 100); + } + return false; + case color.channel2Id: + switch (color.space) { + case 'lch': + case 'oklch': + return fuzzyEquals(channels[1], 0); + } + return false; + } + return false; } - toString(): string { - const isOpaque = fuzzyEquals(this.alpha, 1); - let string = isOpaque ? 'rgb(' : 'rgba('; - string += `${this.red}, ${this.green}, ${this.blue}`; - string += isOpaque ? ')' : `, ${this.alpha})`; - return string; - } + /** + * Returns a color partway between this color and `color2` according to + * `method`, as defined by the CSS Color 4 [color interpolation] procedure. + * + * [color interpolation]: https://www.w3.org/TR/css-color-4/#interpolation + * + * If `method` is missing and this color is in a polar color space (HSL, HWB, + * LCH, and Oklch spaces), `method` defaults to "shorter". + * + * The `weight` is a number between 0 and 1 that indicates how much of this + * color should be in the resulting color. If omitted, it defaults to 0.5. + */ + interpolate( + color2: SassColor, + options?: { + weight?: number; + method?: HueInterpolationMethod; + } + ): SassColor { + const hueInterpolationMethod = + options?.method ?? + (isPolarColorSpace(this.space) ? 'shorter' : undefined); + const weight = options?.weight ?? 0.5; - // Computes `this`'s `hue`, `saturation`, and `lightness` values based on - // `red`, `green`, and `blue`. - // - // Algorithm from https://en.wikipedia.org/wiki/HSL_and_HSV#RGB_to_HSL_and_HSV - private rgbToHsl(): void { - const scaledRed = this.red / 255; - const scaledGreen = this.green / 255; - const scaledBlue = this.blue / 255; - - const max = Math.max(scaledRed, scaledGreen, scaledBlue); - const min = Math.min(scaledRed, scaledGreen, scaledBlue); - const delta = max - min; - - if (max === min) { - this.hueInternal = 0; - } else if (max === scaledRed) { - this.hueInternal = positiveMod( - (60 * (scaledGreen - scaledBlue)) / delta, - 360 - ); - } else if (max === scaledGreen) { - this.hueInternal = positiveMod( - 120 + (60 * (scaledBlue - scaledRed)) / delta, - 360 - ); - } else if (max === scaledBlue) { - this.hueInternal = positiveMod( - 240 + (60 * (scaledRed - scaledGreen)) / delta, - 360 + if (fuzzyEquals(weight, 0)) return color2; + if (fuzzyEquals(weight, 1)) return this; + + if (weight < 0 || weight > 1) { + throw valueError( + `Expected \`weight\` between \`0\` and \`1\`, received \`${weight}\`.` ); } - this.lightnessInternal = 50 * (max + min); + // ColorJS inverses the `weight` argument, where `0` is `this` and `1` is + // `color2`. + const color = this.color.mix(color2.color, 1 - weight, { + space: encodeSpaceForColorJs(this.space), + hue: hueInterpolationMethod, + // @TODO Waiting on new release of ColorJS to fix option types. + // Fixed in: https://github.com/LeaVerou/color.js/pull/347 + } as any); + const coords = decodeCoordsFromColorJs(color.coords, this.space === 'rgb'); + return new SassColor({ + space: this.space, + [this.channel0Id]: coords[0], + [this.channel1Id]: coords[1], + [this.channel2Id]: coords[2], + alpha: NaNtoNull(this.color.alpha), + }); + } - if (max === min) { - this.saturationInternal = 0; - } else if (this.lightnessInternal < 50) { - this.saturationInternal = (100 * delta) / (max + min); - } else { - this.saturationInternal = (100 * delta) / (2 - max - min); + /** Legacy determination of color space by option channels. */ + private getLegacyChangeSpace(options: ConstructorOptions): KnownColorSpace { + let space: KnownColorSpace | undefined; + if ( + isNumberOrNull(options.whiteness) || + isNumberOrNull(options.blackness) || + (this.space === 'hwb' && isNumberOrNull(options.hue)) + ) { + space = 'hwb'; + } else if ( + isNumberOrNull(options.hue) || + isNumberOrNull(options.saturation) || + isNumberOrNull(options.lightness) + ) { + space = 'hsl'; + } else if ( + isNumberOrNull(options.red) || + isNumberOrNull(options.green) || + isNumberOrNull(options.blue) + ) { + space = 'rgb'; } + if (space !== this.space) emitColor4ApiChangeSpaceDeprecation(); + return space ?? this.space; } - // Computes `this`'s red`, `green`, and `blue` channels based on `hue`, - // `saturation`, and `value`. - // - // Algorithm from the CSS3 spec: https://www.w3.org/TR/css3-color/#hsl-color. - private hslToRgb(): void { - const scaledHue = this.hue / 360; - const scaledSaturation = this.saturation / 100; - const scaledLightness = this.lightness / 100; - - const m2 = - scaledLightness <= 0.5 - ? scaledLightness * (scaledSaturation + 1) - : scaledLightness + - scaledSaturation - - scaledLightness * scaledSaturation; - const m1 = scaledLightness * 2 - m2; - - this.redInternal = fuzzyRound(hueToRgb(m1, m2, scaledHue + 1 / 3) * 255); - this.greenInternal = fuzzyRound(hueToRgb(m1, m2, scaledHue) * 255); - this.blueInternal = fuzzyRound(hueToRgb(m1, m2, scaledHue - 1 / 3) * 255); + /** + * Returns a new SassColor in the given `space` that's the result of changing + * one or more of this color's channels. + */ + private getChangedColor( + options: ConstructorOptions, + space: KnownColorSpace, + spaceSetExplicitly: boolean + ): SassColor { + const color = this.toSpace(space); + const getChangedValue = (channel: ChannelName) => { + if (isNumberOrNull(options[channel])) return options[channel]; + return color.channel(channel); + }; + + switch (space) { + case 'hsl': + if (spaceSetExplicitly) { + return new SassColor({ + hue: getChangedValue('hue'), + saturation: getChangedValue('saturation'), + lightness: getChangedValue('lightness'), + alpha: getChangedValue('alpha'), + space, + }); + } else { + checkChangeDeprecations(options, ['hue', 'saturation', 'lightness']); + return new SassColor({ + hue: options.hue ?? color.channel('hue'), + saturation: options.saturation ?? color.channel('saturation'), + lightness: options.lightness ?? color.channel('lightness'), + alpha: options.alpha ?? color.channel('alpha'), + space, + }); + } + + case 'hwb': + if (spaceSetExplicitly) { + return new SassColor({ + hue: getChangedValue('hue'), + whiteness: getChangedValue('whiteness'), + blackness: getChangedValue('blackness'), + alpha: getChangedValue('alpha'), + space, + }); + } else { + checkChangeDeprecations(options, ['hue', 'whiteness', 'blackness']); + return new SassColor({ + hue: options.hue ?? color.channel('hue'), + whiteness: options.whiteness ?? color.channel('whiteness'), + blackness: options.blackness ?? color.channel('blackness'), + alpha: options.alpha ?? color.channel('alpha'), + space, + }); + } + + case 'rgb': + if (spaceSetExplicitly) { + return new SassColor({ + red: getChangedValue('red'), + green: getChangedValue('green'), + blue: getChangedValue('blue'), + alpha: getChangedValue('alpha'), + space, + }); + } else { + checkChangeDeprecations(options, ['red', 'green', 'blue']); + return new SassColor({ + red: options.red ?? color.channel('red'), + green: options.green ?? color.channel('green'), + blue: options.blue ?? color.channel('blue'), + alpha: options.alpha ?? color.channel('alpha'), + space, + }); + } + + case 'lab': + case 'oklab': + return new SassColor({ + lightness: getChangedValue('lightness'), + a: getChangedValue('a'), + b: getChangedValue('b'), + alpha: getChangedValue('alpha'), + space, + }); + + case 'lch': + case 'oklch': + return new SassColor({ + lightness: getChangedValue('lightness'), + chroma: getChangedValue('chroma'), + hue: getChangedValue('hue'), + alpha: getChangedValue('alpha'), + space, + }); + + case 'a98-rgb': + case 'display-p3': + case 'prophoto-rgb': + case 'rec2020': + case 'srgb': + case 'srgb-linear': + return new SassColor({ + red: getChangedValue('red'), + green: getChangedValue('green'), + blue: getChangedValue('blue'), + alpha: getChangedValue('alpha'), + space, + }); + + case 'xyz': + case 'xyz-d50': + case 'xyz-d65': + return new SassColor({ + y: getChangedValue('y'), + x: getChangedValue('x'), + z: getChangedValue('z'), + alpha: getChangedValue('alpha'), + space, + }); + } } -} -// A helper for converting HWB colors to RGB. -function hwbToRgb( - hue: number, - scaledWhiteness: number, - scaledBlackness: number -) { - const factor = 1 - scaledWhiteness - scaledBlackness; - const channel = hueToRgb(0, 1, hue) * factor + scaledWhiteness; - return fuzzyRound(channel * 255); -} + /** + * Returns a new color that's the result of changing one or more of this + * color's channels. + */ + change( + options: { + [key in ChannelNameHsl]?: number | null; + } & { + space?: ColorSpaceHsl; + } + ): SassColor; + change( + options: { + [key in ChannelNameHwb]?: number | null; + } & { + space?: ColorSpaceHwb; + } + ): SassColor; + change( + options: { + [key in ChannelNameLab]?: number | null; + } & { + space?: ColorSpaceLab; + } + ): SassColor; + change( + options: { + [key in ChannelNameLch]?: number | null; + } & { + space?: ColorSpaceLch; + } + ): SassColor; + change( + options: { + [key in ChannelNameRgb]?: number | null; + } & { + space?: ColorSpaceRgb; + } + ): SassColor; + change( + options: { + [key in ChannelNameXyz]?: number | null; + } & { + space?: ColorSpaceXyz; + } + ): SassColor; + change(options: ConstructorOptions) { + const spaceSetExplicitly = !!options.space; + let space = options.space ?? this.space; + if (this.isLegacy && !spaceSetExplicitly) { + space = this.getLegacyChangeSpace(options); + } + + // Validate channel values + const keys = Object.keys(options).filter( + key => key !== 'space' + ) as ChannelName[]; + for (const channel of keys) { + validateChannelInSpace(channel, space); + } + if (isNumberOrNull(options.alpha) && options.alpha !== null) { + fuzzyAssertInRange(options.alpha, 0, 1, 'alpha'); + } + if (isNumberOrNull(options.lightness) && options.lightness !== null) { + const maxLightness = space === 'oklab' || space === 'oklch' ? 1 : 100; + assertClamped(options.lightness, 0, maxLightness, 'lightness'); + } + + return this.getChangedColor(options, space, spaceSetExplicitly).toSpace( + this.space + ); + } -// An algorithm from the CSS3 spec: http://www.w3.org/TR/css3-color/#hsl-color. -function hueToRgb(m1: number, m2: number, hue: number): number { - if (hue < 0) hue += 1; - if (hue > 1) hue -= 1; - - if (hue < 1 / 6) { - return m1 + (m2 - m1) * hue * 6; - } else if (hue < 1 / 2) { - return m2; - } else if (hue < 2 / 3) { - return m1 + (m2 - m1) * (2 / 3 - hue) * 6; - } else { - return m1; + equals(other: Value): boolean { + if (!(other instanceof SassColor)) return false; + let coords = this.color.coords; + let otherCoords = other.color.coords; + if (this.isLegacy) { + if (!other.isLegacy) return false; + if (!fuzzyEquals(this.alpha, other.alpha)) return false; + if (!(this.space === 'rgb' && other.space === 'rgb')) { + coords = this.color + .to('srgb') + .coords.map(coordToRgb) + .map(fuzzyRound) as [number, number, number]; + otherCoords = other.color + .to('srgb') + .coords.map(coordToRgb) + .map(fuzzyRound) as [number, number, number]; + } + return ( + fuzzyEquals(coords[0], otherCoords[0]) && + fuzzyEquals(coords[1], otherCoords[1]) && + fuzzyEquals(coords[2], otherCoords[2]) + ); + } + return ( + this.space === other.space && + fuzzyEquals(coords[0], otherCoords[0]) && + fuzzyEquals(coords[1], otherCoords[1]) && + fuzzyEquals(coords[2], otherCoords[2]) && + fuzzyEquals(this.alpha, other.alpha) + ); + } + + hashCode(): number { + let coords = this.color.coords; + if (this.isLegacy) { + coords = this.color.to('srgb').coords.map(coordToRgb).map(fuzzyRound) as [ + number, + number, + number, + ]; + return ( + fuzzyHashCode(coords[0]) ^ + fuzzyHashCode(coords[1]) ^ + fuzzyHashCode(coords[2]) ^ + fuzzyHashCode(this.alpha) + ); + } + return ( + hash(this.space) ^ + fuzzyHashCode(coords[0]) ^ + fuzzyHashCode(coords[1]) ^ + fuzzyHashCode(coords[2]) ^ + fuzzyHashCode(this.alpha) + ); + } + + toString(): string { + return this.color.toString({inGamut: false}); } } diff --git a/package.json b/package.json index 946c967b..f0edc305 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sass-embedded", "version": "1.69.5", - "protocol-version": "2.3.0", + "protocol-version": "3.0.0-dev", "compiler-version": "1.69.5", "description": "Node.js library that communicates with Embedded Dart Sass using the Embedded Sass protocol", "repository": "sass/embedded-host-node", @@ -21,7 +21,7 @@ "dist/**/*" ], "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0" }, "scripts": { "init": "ts-node ./tool/init.ts", @@ -47,6 +47,7 @@ "dependencies": { "@bufbuild/protobuf": "^1.0.0", "buffer-builder": "^0.2.0", + "colorjs.io": "^0.4.5", "immutable": "^4.0.0", "rxjs": "^7.4.0", "supports-color": "^8.1.1", diff --git a/tsconfig.json b/tsconfig.json index c839cc1e..35577090 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,6 @@ "resolveJsonModule": true, "rootDir": ".", "useUnknownInCatchVariables": false, - "resolveJsonModule": true, "declaration": false, "lib": ["DOM"] }, From 00524d8d5249d3f0853248d2aced85876ae825db Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 27 Mar 2024 13:32:47 -0700 Subject: [PATCH 07/14] Update colorjs.io --- lib/src/value/color.ts | 4 +--- package.json | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index b8bcaa36..23294b24 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -882,9 +882,7 @@ export class SassColor extends Value { const color = this.color.mix(color2.color, 1 - weight, { space: encodeSpaceForColorJs(this.space), hue: hueInterpolationMethod, - // @TODO Waiting on new release of ColorJS to fix option types. - // Fixed in: https://github.com/LeaVerou/color.js/pull/347 - } as any); + }); const coords = decodeCoordsFromColorJs(color.coords, this.space === 'rgb'); return new SassColor({ space: this.space, diff --git a/package.json b/package.json index 6c366c9d..a4f62a4f 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "dependencies": { "@bufbuild/protobuf": "^1.0.0", "buffer-builder": "^0.2.0", - "colorjs.io": "^0.4.5", + "colorjs.io": "^0.5.0", "immutable": "^4.0.0", "rxjs": "^7.4.0", "supports-color": "^8.1.1", From 6cdf30a4455885631be1ae8e37222959d286816e Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 27 Mar 2024 13:36:59 -0700 Subject: [PATCH 08/14] Reformat --- lib/src/importer-registry.ts | 12 ++++++------ lib/src/legacy/importer.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/src/importer-registry.ts b/lib/src/importer-registry.ts index 7208950a..9a1cb337 100644 --- a/lib/src/importer-registry.ts +++ b/lib/src/importer-registry.ts @@ -21,12 +21,12 @@ export class NodePackageImporter { entryPointDirectory = entryPointDirectory ? p.resolve(entryPointDirectory) : require.main?.filename - ? p.dirname(require.main.filename) - : // TODO: Find a way to use `import.meta.main` once - // https://github.com/nodejs/node/issues/49440 is done. - process.argv[1] - ? createRequire(process.argv[1]).resolve(process.argv[1]) - : undefined; + ? p.dirname(require.main.filename) + : // TODO: Find a way to use `import.meta.main` once + // https://github.com/nodejs/node/issues/49440 is done. + process.argv[1] + ? createRequire(process.argv[1]).resolve(process.argv[1]) + : undefined; if (!entryPointDirectory) { throw new Error( 'The Node package importer cannot determine an entry point ' + diff --git a/lib/src/legacy/importer.ts b/lib/src/legacy/importer.ts index 791cfc12..97ef9117 100644 --- a/lib/src/legacy/importer.ts +++ b/lib/src/legacy/importer.ts @@ -216,8 +216,8 @@ export class LegacyImporterWrapper const syntax = canonicalUrl.pathname.endsWith('.sass') ? 'indented' : canonicalUrl.pathname.endsWith('.css') - ? 'css' - : 'scss'; + ? 'css' + : 'scss'; let contents = this.lastContents ?? From fef63f38d9d4a178893345a52b3ac901561dd6b1 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 9 Apr 2024 17:15:37 -0700 Subject: [PATCH 09/14] [Color 4] Update behavior to match latest specs (#278) --- lib/src/value/color.ts | 50 +++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 23294b24..022d559a 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -7,7 +7,9 @@ import {valueError} from '../utils'; import { fuzzyAssertInRange, fuzzyEquals, + fuzzyGreaterThanOrEquals, fuzzyHashCode, + fuzzyLessThan, fuzzyRound, positiveMod, } from './utils'; @@ -123,19 +125,6 @@ function NaNtoZero(val: number): number { return Number.isNaN(val) ? 0 : val; } -/** - * Assert that `val` is either `NaN` or within `min` and `max`. Otherwise, - * throw an error. - */ -function assertClamped( - val: number, - min: number, - max: number, - name: string -): number { - return Number.isNaN(val) ? val : fuzzyAssertInRange(val, min, max, name); -} - /** Convert from sRGB (0-1) to RGB (0-255) units. */ function coordToRgb(val: number): number { return val * 255; @@ -493,10 +482,14 @@ export class SassColor extends Value { break; case 'hsl': { - const hue = normalizeHue(options.hue ?? NaN); - const saturation = options.saturation ?? NaN; - let lightness = options.lightness ?? NaN; - lightness = assertClamped(lightness, 0, 100, 'lightness'); + let hue = normalizeHue(options.hue ?? NaN); + let saturation = options.saturation ?? NaN; + const lightness = options.lightness ?? NaN; + if (!Number.isNaN(saturation) && fuzzyLessThan(saturation, 0)) { + saturation = Math.abs(saturation); + hue = (hue + 180) % 360; + } + this.color = new Color({ spaceId: encodeSpaceForColorJs(space), coords: [hue, saturation, lightness], @@ -519,11 +512,9 @@ export class SassColor extends Value { case 'lab': case 'oklab': { - let lightness = options.lightness ?? NaN; + const lightness = options.lightness ?? NaN; const a = options.a ?? NaN; const b = options.b ?? NaN; - const maxLightness = space === 'lab' ? 100 : 1; - lightness = assertClamped(lightness, 0, maxLightness, 'lightness'); this.color = new Color({ spaceId: encodeSpaceForColorJs(space), coords: [lightness, a, b], @@ -534,11 +525,14 @@ export class SassColor extends Value { case 'lch': case 'oklch': { - let lightness = options.lightness ?? NaN; - const chroma = options.chroma ?? NaN; - const hue = normalizeHue(options.hue ?? NaN); - const maxLightness = space === 'lch' ? 100 : 1; - lightness = assertClamped(lightness, 0, maxLightness, 'lightness'); + const lightness = options.lightness ?? NaN; + let chroma = options.chroma ?? NaN; + let hue = normalizeHue(options.hue ?? NaN); + if (!Number.isNaN(chroma) && fuzzyLessThan(chroma, 0)) { + chroma = Math.abs(chroma); + hue = (hue + 180) % 360; + } + this.color = new Color({ spaceId: encodeSpaceForColorJs(space), coords: [lightness, chroma, hue], @@ -830,7 +824,7 @@ export class SassColor extends Value { case color.channel0Id: if (color.space === 'hsl') return fuzzyEquals(channels[1], 0); if (color.space === 'hwb') { - return fuzzyEquals(channels[1] + channels[2], 100); + return fuzzyGreaterThanOrEquals(channels[1] + channels[2], 100); } return false; case color.channel2Id: @@ -1105,10 +1099,6 @@ export class SassColor extends Value { if (isNumberOrNull(options.alpha) && options.alpha !== null) { fuzzyAssertInRange(options.alpha, 0, 1, 'alpha'); } - if (isNumberOrNull(options.lightness) && options.lightness !== null) { - const maxLightness = space === 'oklab' || space === 'oklch' ? 1 : 100; - assertClamped(options.lightness, 0, maxLightness, 'lightness'); - } return this.getChangedColor(options, space, spaceSetExplicitly).toSpace( this.space From a843bd5897e7cda5895e50d82c1ebd824b7d3951 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 11 Apr 2024 16:24:55 -0700 Subject: [PATCH 10/14] Add new deprecations --- lib/src/deprecations.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/src/deprecations.ts b/lib/src/deprecations.ts index 7fe782d3..f69284cc 100644 --- a/lib/src/deprecations.ts +++ b/lib/src/deprecations.ts @@ -157,6 +157,20 @@ export const deprecations: typeof api.deprecations = { description: 'Using the current working directory as an implicit load path.', }, + 'color-4-api': { + id: 'color-4-api', + status: 'active', + deprecatedIn: new Version(1, 76, 0), + obsoleteIn: null, + description: 'Methods of interacting with legacy SassColors.', + }, + 'color-functions': { + id: 'color-functions', + status: 'active', + deprecatedIn: new Version(1, 76, 0), + obsoleteIn: null, + description: 'Using global Sass color functions.', + }, import: { id: 'import', status: 'future', From 6e9368eac72412cac2d763c2c69f6ad7b909f780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=AA=E3=81=A4=E3=81=8D?= Date: Fri, 12 Apr 2024 16:17:07 -0700 Subject: [PATCH 11/14] Fix test regression caused by node fixing CVE-2024-27980 (#286) --- lib/src/compiler/async.ts | 4 ++++ lib/src/compiler/sync.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/lib/src/compiler/async.ts b/lib/src/compiler/async.ts index 6b4d77a4..417b82a9 100644 --- a/lib/src/compiler/async.ts +++ b/lib/src/compiler/async.ts @@ -43,6 +43,10 @@ export class AsyncCompiler { // current working directory. // https://github.com/sass/embedded-host-node/pull/261#discussion_r1438712923 cwd: path.dirname(compilerCommand[0]), + // Node blocks launching .bat and .cmd without a shell due to CVE-2024-27980 + shell: ['.bat', '.cmd'].includes( + path.extname(compilerCommand[0]).toLowerCase() + ), windowsHide: true, } ); diff --git a/lib/src/compiler/sync.ts b/lib/src/compiler/sync.ts index 50b7bb33..33838e0e 100644 --- a/lib/src/compiler/sync.ts +++ b/lib/src/compiler/sync.ts @@ -43,6 +43,10 @@ export class Compiler { // current working directory. // https://github.com/sass/embedded-host-node/pull/261#discussion_r1438712923 cwd: path.dirname(compilerCommand[0]), + // Node blocks launching .bat and .cmd without a shell due to CVE-2024-27980 + shell: ['.bat', '.cmd'].includes( + path.extname(compilerCommand[0]).toLowerCase() + ), windowsHide: true, } ); From 7d44ff35b0727c7aa7cc5c7984a98f72f4961593 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 19 Apr 2024 16:18:28 -0700 Subject: [PATCH 12/14] Add a method parameter to toGamut (#288) --- lib/src/value/color.ts | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 022d559a..5febe920 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -90,6 +90,12 @@ type HueInterpolationMethod = | 'longer' | 'shorter'; +/** + * Methods by which colors in bounded spaces can be mapped to within their + * gamut. + */ +type GamutMapMethod = 'clip' | 'local-minde'; + /** Options for specifying any channel value. */ type ChannelOptions = { [key in ChannelName]?: number | null; @@ -153,6 +159,14 @@ function encodeSpaceForColorJs(space?: KnownColorSpace): string | undefined { return space; } +/** + * Normalize discrepancies between Sass's [GamutMapMethod] and Color.js's + * `method` option. + */ +function encodeGamutMapMethodForColorJs(method: GamutMapMethod): string { + return method === 'local-minde' ? 'css' : method; +} + /** * Normalize discrepancies between Sass color spaces and ColorJS color space * ids, converting ColorJS values to Sass values. @@ -714,18 +728,21 @@ export class SassColor extends Value { /** * Returns a copy of this color, modified so it is in-gamut for the specified - * `space`—or the current color space if `space` is not specified—using the - * recommended [CSS Gamut Mapping Algorithm][css-mapping] to map out-of-gamut - * colors into the desired gamut with as little perceptual change as possible. - * - * [css-mapping]: - * https://www.w3.org/TR/css-color-4/#css-gamut-mapping-algorithm + * `space`—or the current color space if `space` is not specified—using + * `method` to map out-of-gamut colors into the desired gamut. */ - toGamut(space?: KnownColorSpace): SassColor { + toGamut({ + space, + method, + }: { + space?: KnownColorSpace; + method: GamutMapMethod; + }): SassColor { if (this.isInGamut(space)) return this; - const color = this.color - .clone() - .toGamut({space: encodeSpaceForColorJs(space)}); + const color = this.color.clone().toGamut({ + space: encodeSpaceForColorJs(space), + method: encodeGamutMapMethodForColorJs(method), + }); return new SassColor({color, space: space ?? this.space}); } From d2a3cbc1376c64070714ce80ead702ccc2791f58 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Mon, 12 Aug 2024 15:39:51 -0700 Subject: [PATCH 13/14] Fix style issues --- lib/src/protofier.ts | 2 +- lib/src/value/color.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/src/protofier.ts b/lib/src/protofier.ts index c9c28e39..0347364a 100644 --- a/lib/src/protofier.ts +++ b/lib/src/protofier.ts @@ -8,7 +8,7 @@ import * as proto from './vendor/embedded_sass_pb'; import * as utils from './utils'; import {FunctionRegistry} from './function-registry'; import {SassArgumentList} from './value/argument-list'; -import {SassColor, KnownColorSpace} from './value/color'; +import {KnownColorSpace, SassColor} from './value/color'; import {SassFunction} from './value/function'; import {ListSeparator, SassList} from './value/list'; import {SassMap} from './value/map'; diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index 5febe920..5cf260d4 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -295,7 +295,7 @@ function checkChangeDeprecations( [key in ChannelName]?: number | null; }, channels: ChannelName[] -) { +): void { if (options.alpha === null) emitNullAlphaDeprecation(); for (const channel of channels) { if (options[channel] === null) emitColor4ApiChangeNullDeprecation(channel); @@ -303,7 +303,7 @@ function checkChangeDeprecations( } /** Warn users about legacy color channel getters. */ -function emitColor4ApiGetterDeprecation(name: string) { +function emitColor4ApiGetterDeprecation(name: string): void { console.warn( 'Deprecation [color-4-api]: ' + `\`${name}\` is deprecated, use \`channel\` instead.` + @@ -316,7 +316,7 @@ function emitColor4ApiGetterDeprecation(name: string) { * Warn users about changing channels not in the current color space without * explicitly setting `space`. */ -function emitColor4ApiChangeSpaceDeprecation() { +function emitColor4ApiChangeSpaceDeprecation(): void { console.warn( 'Deprecation [color-4-api]: ' + "Changing a channel not in this color's space without explicitly " + @@ -327,7 +327,7 @@ function emitColor4ApiChangeSpaceDeprecation() { } /** Warn users about `null` channel values without setting `space`. */ -function emitColor4ApiChangeNullDeprecation(channel: string) { +function emitColor4ApiChangeNullDeprecation(channel: string): void { console.warn( 'Deprecation [color-4-api]: ' + `Passing \`${channel}: null\` without setting \`space\` is deprecated.` + @@ -337,7 +337,7 @@ function emitColor4ApiChangeNullDeprecation(channel: string) { } /** Warn users about null-alpha deprecation. */ -function emitNullAlphaDeprecation() { +function emitNullAlphaDeprecation(): void { console.warn( 'Deprecation [null-alpha]: ' + 'Passing `alpha: null` without setting `space` is deprecated.' + @@ -375,7 +375,7 @@ export class SassColor extends Value { private channel2Id!: ChannelName; // Sets channel names based on this color's color space - private setChannelIds(space: KnownColorSpace) { + private setChannelIds(space: KnownColorSpace): void { switch (space) { case 'rgb': case 'srgb': @@ -940,10 +940,10 @@ export class SassColor extends Value { spaceSetExplicitly: boolean ): SassColor { const color = this.toSpace(space); - const getChangedValue = (channel: ChannelName) => { + function getChangedValue(channel: ChannelName): number | null { if (isNumberOrNull(options[channel])) return options[channel]; return color.channel(channel); - }; + } switch (space) { case 'hsl': @@ -1099,7 +1099,7 @@ export class SassColor extends Value { space?: ColorSpaceXyz; } ): SassColor; - change(options: ConstructorOptions) { + change(options: ConstructorOptions): SassColor { const spaceSetExplicitly = !!options.space; let space = options.space ?? this.space; if (this.isLegacy && !spaceSetExplicitly) { From e146240097bb8753e2046cc640d80e38b293ac42 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 12 Sep 2024 18:47:22 -0700 Subject: [PATCH 14/14] Remove -dev from embedded protocol --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 985fb940..6e1707c3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sass-embedded", "version": "1.78.0", - "protocol-version": "3.0.0-dev", + "protocol-version": "3.0.0", "compiler-version": "1.78.0", "description": "Node.js library that communicates with Embedded Dart Sass using the Embedded Sass protocol", "repository": "sass/embedded-host-node",