diff --git a/package.json b/package.json index eee8f789..37aa4e7c 100644 --- a/package.json +++ b/package.json @@ -277,6 +277,7 @@ "react-refresh": "^0.17.0", "react-test-renderer": "19.2.3", "release-it": "^20.2.1", + "sass": "^1.106.1", "tailwindcss": "^4.1.12", "tailwindcss-safe-area": "^1.1.0", "typescript": "~6.0.3", diff --git a/src/__tests__/metro/_expo-worker.ts b/src/__tests__/metro/_expo-worker.ts new file mode 100644 index 00000000..1597f3a2 --- /dev/null +++ b/src/__tests__/metro/_expo-worker.ts @@ -0,0 +1,135 @@ +/** + * A stand-in for Expo's transform worker, so the Metro transformer can be driven without Metro. + * + * Its web branch does what `@expo/metro-config`'s `transformCss` does to a global stylesheet: the + * project's PostCSS, Sass for a `.scss` or `.sass` file, then lightningcss against the project's + * browser targets. The target here is + * one browser without `:dir()` support, so the lowering that pass performs for real (a `:dir()` + * becomes a `:lang()` list) happens here for real too. Every other file is echoed back as a bare + * JS module, and the stylesheet the transformer injects in a stylesheet's place is read back from + * the module it hands over. + */ +import { transformPostCssModule } from "@expo/metro-config/build/transform-worker/postcss"; +import { + compileSass, + matchSass, +} from "@expo/metro-config/build/transform-worker/sass"; +import { Features, transform as transformStylesheet } from "lightningcss"; +import type { + JsTransformerConfig, + JsTransformOptions, + transform as workerTransform, +} from "metro-transform-worker"; +import type { ReactNativeCssStyleSheet } from "react-native-css/compiler"; + +type TransformResponse = Awaited>; + +/** The response with the CSS metadata the transformer attaches — the shape it declares itself. */ +export type StylesheetTransformResponse = TransformResponse & { + readonly output: readonly [{ readonly data: { readonly css: unknown } }]; +}; + +/** Expo attaches the stylesheet's CSS to the web build's module output. */ +type BrowserBuildOutput = TransformResponse["output"][number] & { + readonly data: { readonly css: { readonly code: Buffer } }; +}; + +export interface RecordedTransform { + readonly filePath: string; + readonly platform: JsTransformOptions["platform"]; + readonly source: string; +} + +export const recordedTransforms: RecordedTransform[] = []; + +export function injectedStylesheet( + stylesheetPath: string, +): ReactNativeCssStyleSheet { + const injection = recordedTransforms.find( + ({ filePath }) => filePath === `${stylesheetPath}.js`, + ); + if (!injection) { + throw new Error( + "the transformer never handed the worker an injection module", + ); + } + const match = /StyleCollection\.inject\((.*)\);/s.exec(injection.source); + if (!match?.[1]) { + throw new Error("the injection module carries no stylesheet"); + } + return JSON.parse(match[1]) as ReactNativeCssStyleSheet; +} + +export function rulesFor( + stylesheet: ReactNativeCssStyleSheet, + className: string, +): string | undefined { + const entry = stylesheet.s?.find(([name]) => name === className); + return entry ? JSON.stringify(entry[1]) : undefined; +} + +/** Loading and running the stylesheet pipeline outlasts jest's 5 s default on a loaded machine. */ +export const TRANSFORM_TIMEOUT_MS = 60_000; + +/** Chrome 100 predates `:dir()` (Chrome 120), in lightningcss's `major << 16` encoding. */ +const CHROME_100 = 100 << 16; + +const jsModule = (code: string): TransformResponse => ({ + dependencies: [], + output: [ + { + type: "js/module", + data: { + code, + lineCount: code.split("\n").length, + map: [], + functionMap: null, + }, + }, + ], +}); + +export async function transform( + _config: JsTransformerConfig, + projectRoot: string, + filePath: string, + data: Buffer, + options: JsTransformOptions, +): Promise { + recordedTransforms.push({ + filePath, + platform: options.platform, + source: data.toString(), + }); + + if (options.platform === "web" && /\.(s?css|sass)$/.test(filePath)) { + const { src } = await transformPostCssModule(projectRoot, { + src: data.toString(), + filename: filePath, + }); + const syntax = matchSass(filePath); + const css = syntax + ? compileSass(projectRoot, { filename: filePath, src }, { syntax }).src + : src; + const { code } = transformStylesheet({ + filename: filePath, + code: Buffer.from(css), + targets: { chrome: CHROME_100 }, + include: Features.Nesting, + errorRecovery: true, + }); + const browserBuild: BrowserBuildOutput = { + type: "js/module", + data: { + code: "", + lineCount: 0, + map: [], + functionMap: null, + css: { code: Buffer.from(code) }, + }, + }; + return { dependencies: [], output: [browserBuild] }; + } + + return jsModule(data.toString()); +} diff --git a/src/__tests__/metro/_project-plain/global.css b/src/__tests__/metro/_project-plain/global.css new file mode 100644 index 00000000..6d58661a --- /dev/null +++ b/src/__tests__/metro/_project-plain/global.css @@ -0,0 +1,6 @@ +.plain { + color: red; +} +.plain:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { + color: blue; +} diff --git a/src/__tests__/metro/_project-plain/indented.sass b/src/__tests__/metro/_project-plain/indented.sass new file mode 100644 index 00000000..333e3d46 --- /dev/null +++ b/src/__tests__/metro/_project-plain/indented.sass @@ -0,0 +1,4 @@ +$accent: #00f + +.indented + color: $accent diff --git a/src/__tests__/metro/_project-sass/from-postcss.ts b/src/__tests__/metro/_project-sass/from-postcss.ts new file mode 100644 index 00000000..4d47380c --- /dev/null +++ b/src/__tests__/metro/_project-sass/from-postcss.ts @@ -0,0 +1,15 @@ +import type { Plugin } from "postcss"; + +/** Appends a rule only Sass can compile, so the native stylesheet holds it only if Sass compiled this plugin's output. */ +const fromPostcss: Plugin = { + postcssPlugin: "from-postcss", + Once(root, { Declaration, Rule }) { + root.append( + new Rule({ selector: ".from-postcss" }).append( + new Declaration({ prop: "color", value: "$brand" }), + ), + ); + }, +}; + +export default fromPostcss; diff --git a/src/__tests__/metro/_project-sass/global.scss b/src/__tests__/metro/_project-sass/global.scss new file mode 100644 index 00000000..492e5434 --- /dev/null +++ b/src/__tests__/metro/_project-sass/global.scss @@ -0,0 +1,5 @@ +$brand: #f00; + +.sassy { + color: $brand; +} diff --git a/src/__tests__/metro/_project-sass/postcss.config.json b/src/__tests__/metro/_project-sass/postcss.config.json new file mode 100644 index 00000000..9a270b5e --- /dev/null +++ b/src/__tests__/metro/_project-sass/postcss.config.json @@ -0,0 +1,5 @@ +{ + "plugins": { + "./from-postcss.ts": {} + } +} diff --git a/src/__tests__/metro/_project/global.css b/src/__tests__/metro/_project/global.css new file mode 100644 index 00000000..d2c29056 --- /dev/null +++ b/src/__tests__/metro/_project/global.css @@ -0,0 +1,6 @@ +@import "tailwindcss/utilities.css"; +@theme { + --color-brand: oklch(62.3% 0.214 259.815); +} +@source not "**/*"; +@source inline("bg-[#00f] rtl:bg-[#f00] bg-brand"); diff --git a/src/__tests__/metro/_project/postcss.config.json b/src/__tests__/metro/_project/postcss.config.json new file mode 100644 index 00000000..e092dc7c --- /dev/null +++ b/src/__tests__/metro/_project/postcss.config.json @@ -0,0 +1,5 @@ +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} diff --git a/src/__tests__/metro/native-stylesheet-input.test.ts b/src/__tests__/metro/native-stylesheet-input.test.ts new file mode 100644 index 00000000..3be62560 --- /dev/null +++ b/src/__tests__/metro/native-stylesheet-input.test.ts @@ -0,0 +1,144 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import type { + JsTransformerConfig, + JsTransformOptions, +} from "metro-transform-worker"; + +import { transform } from "../../metro/metro-transformer"; +import { + injectedStylesheet, + recordedTransforms, + rulesFor, + TRANSFORM_TIMEOUT_MS, + type StylesheetTransformResponse, +} from "./_expo-worker"; + +jest.mock("@expo/metro-config", () => ({ + unstable_transformerPath: require.resolve("./_expo-worker"), +})); + +jest.setTimeout(TRANSFORM_TIMEOUT_MS); + +const PROJECT_ROOT = path.join(__dirname, "_project"); +const STYLESHEET_PATH = path.join(PROJECT_ROOT, "global.css"); + +// The transformer forwards the config to the worker untouched and reads nothing else from it. +const CONFIG = {} as JsTransformerConfig; + +const NATIVE_OPTIONS: JsTransformOptions = { + dev: true, + inlinePlatform: false, + inlineRequires: false, + minify: false, + platform: "android", + type: "module", + unstable_transformProfile: "default", +}; + +let response: StylesheetTransformResponse; + +beforeAll(async () => { + response = (await transform( + CONFIG, + PROJECT_ROOT, + STYLESHEET_PATH, + readFileSync(STYLESHEET_PATH), + NATIVE_OPTIONS, + )) as StylesheetTransformResponse; +}); + +test("the native stylesheet is what the project's PostCSS produced", () => { + // Only Tailwind emits this class; the authored stylesheet merely names it. + expect(rulesFor(injectedStylesheet(STYLESHEET_PATH), "bg-[#00f]")).toContain( + '"backgroundColor":"#00f"', + ); +}); + +test("a theme colour declared once is inlined, because no browser build doubled its declaration", () => { + const brandRule = rulesFor(injectedStylesheet(STYLESHEET_PATH), "bg-brand"); + + expect(brandRule).toContain('"backgroundColor":"#'); + expect(brandRule).not.toContain('"var"'); +}); + +test("the stylesheet's own output is emptied and marked uncacheable, since PostCSS reads other files", () => { + expect(response.output[0].data.css).toStrictEqual({ + skipCache: true, + code: "", + }); +}); + +test("the native stylesheet is compiled from the authored CSS, never from a browser build of it", () => { + const rtlRule = rulesFor( + injectedStylesheet(STYLESHEET_PATH), + "rtl:bg-[#f00]", + ); + + expect(rtlRule).toContain('"dir"'); + expect(rtlRule).toContain('"rtl"'); + expect( + recordedTransforms.filter(({ filePath }) => filePath === STYLESHEET_PATH), + ).toStrictEqual([]); +}); + +test("the injection module replaces the stylesheet as the module's code", () => { + const injection = recordedTransforms.find( + ({ filePath }) => filePath === `${STYLESHEET_PATH}.js`, + ); + + expect(injection?.platform).toBe("android"); + expect(injection?.source).toContain( + 'import { StyleCollection } from "react-native-css/native-internal";', + ); +}); + +test("a web build of the stylesheet is the worker's, untouched", async () => { + const source = readFileSync(STYLESHEET_PATH); + const webResponse = (await transform( + CONFIG, + PROJECT_ROOT, + STYLESHEET_PATH, + source, + { + ...NATIVE_OPTIONS, + platform: "web", + }, + )) as StylesheetTransformResponse; + + const webTransform = recordedTransforms.find( + ({ filePath, platform }) => + filePath === STYLESHEET_PATH && platform === "web", + ); + expect(webTransform?.source).toBe(source.toString()); + expect(webResponse.output[0].data.css).toBeDefined(); +}); + +test("a module that is not a stylesheet reaches the worker untouched", async () => { + const modulePath = path.join(PROJECT_ROOT, "module.js"); + const source = Buffer.from("export const untouched = 1;\n"); + + await transform(CONFIG, PROJECT_ROOT, modulePath, source, NATIVE_OPTIONS); + + const moduleTransform = recordedTransforms.find( + ({ filePath }) => filePath === modulePath, + ); + expect(moduleTransform?.platform).toBe("android"); + expect(moduleTransform?.source).toBe(source.toString()); +}); + +test("a stylesheet requested as an asset reaches the worker untouched", async () => { + const assetPath = path.join(PROJECT_ROOT, "asset.css"); + const source = Buffer.from(".asset { color: red; }\n"); + + await transform(CONFIG, PROJECT_ROOT, assetPath, source, { + ...NATIVE_OPTIONS, + type: "asset", + }); + + const assetTransform = recordedTransforms.find( + ({ filePath }) => filePath === assetPath, + ); + expect(assetTransform?.source).toBe(source.toString()); +}); diff --git a/src/__tests__/metro/native-stylesheet-plain-project.test.ts b/src/__tests__/metro/native-stylesheet-plain-project.test.ts new file mode 100644 index 00000000..cade7613 --- /dev/null +++ b/src/__tests__/metro/native-stylesheet-plain-project.test.ts @@ -0,0 +1,84 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import type { + JsTransformerConfig, + JsTransformOptions, +} from "metro-transform-worker"; + +import { transform } from "../../metro/metro-transformer"; +import { + injectedStylesheet, + recordedTransforms, + rulesFor, + TRANSFORM_TIMEOUT_MS, +} from "./_expo-worker"; + +jest.mock("@expo/metro-config", () => ({ + unstable_transformerPath: require.resolve("./_expo-worker"), +})); + +jest.setTimeout(TRANSFORM_TIMEOUT_MS); + +// Its own file: Expo resolves a project's PostCSS pipeline once per process, so a project WITHOUT a +// config has to be the first one this process sees. +const PROJECT_ROOT = path.join(__dirname, "_project-plain"); +const STYLESHEET_PATH = path.join(PROJECT_ROOT, "global.css"); +const INDENTED_STYLESHEET_PATH = path.join(PROJECT_ROOT, "indented.sass"); + +const CONFIG = {} as JsTransformerConfig; + +const NATIVE_OPTIONS: JsTransformOptions = { + dev: true, + inlinePlatform: false, + inlineRequires: false, + minify: false, + platform: "ios", + type: "module", + unstable_transformProfile: "default", +}; + +beforeAll(async () => { + await transform( + CONFIG, + PROJECT_ROOT, + STYLESHEET_PATH, + readFileSync(STYLESHEET_PATH), + NATIVE_OPTIONS, + ); +}); + +test("a project with no PostCSS config compiles the stylesheet as authored", () => { + const rules = injectedStylesheet(STYLESHEET_PATH).s?.find( + ([name]) => name === "plain", + )?.[1]; + + expect(rules).toHaveLength(2); + expect(JSON.stringify(rules)).toContain('"dir"'); + expect( + recordedTransforms.filter(({ filePath }) => filePath === STYLESHEET_PATH), + ).toStrictEqual([]); +}); + +test("an indented .sass stylesheet is compiled by Dart Sass before the native compiler sees it", async () => { + await transform( + CONFIG, + PROJECT_ROOT, + INDENTED_STYLESHEET_PATH, + readFileSync(INDENTED_STYLESHEET_PATH), + NATIVE_OPTIONS, + ); + + // `$accent` and the indented block are Sass alone, so the colour reaches the stylesheet only once Sass compiled it. + expect( + rulesFor(injectedStylesheet(INDENTED_STYLESHEET_PATH), "indented"), + ).toContain('"color":"#00f"'); +}); + +test("the injection module is transformed for the platform that asked", () => { + expect( + recordedTransforms.find( + ({ filePath }) => filePath === `${STYLESHEET_PATH}.js`, + )?.platform, + ).toBe("ios"); +}); diff --git a/src/__tests__/metro/native-stylesheet-sass.test.ts b/src/__tests__/metro/native-stylesheet-sass.test.ts new file mode 100644 index 00000000..8d456601 --- /dev/null +++ b/src/__tests__/metro/native-stylesheet-sass.test.ts @@ -0,0 +1,93 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import type { + JsTransformerConfig, + JsTransformOptions, +} from "metro-transform-worker"; + +import { transform } from "../../metro/metro-transformer"; +import { + injectedStylesheet, + recordedTransforms, + rulesFor, + TRANSFORM_TIMEOUT_MS, + type StylesheetTransformResponse, +} from "./_expo-worker"; + +jest.mock("@expo/metro-config", () => ({ + unstable_transformerPath: require.resolve("./_expo-worker"), +})); + +jest.setTimeout(TRANSFORM_TIMEOUT_MS); + +const PROJECT_ROOT = path.join(__dirname, "_project-sass"); +const STYLESHEET_PATH = path.join(PROJECT_ROOT, "global.scss"); + +const CONFIG = {} as JsTransformerConfig; + +const NATIVE_OPTIONS: JsTransformOptions = { + dev: true, + inlinePlatform: false, + inlineRequires: false, + minify: false, + platform: "android", + type: "module", + unstable_transformProfile: "default", +}; + +beforeAll(async () => { + await transform( + CONFIG, + PROJECT_ROOT, + STYLESHEET_PATH, + readFileSync(STYLESHEET_PATH), + NATIVE_OPTIONS, + ); +}); + +test("a .scss stylesheet is compiled by Dart Sass before the native compiler sees it", () => { + // `$brand` is a Sass variable, so the colour reaches the stylesheet only once Sass resolved it. + expect(rulesFor(injectedStylesheet(STYLESHEET_PATH), "sassy")).toContain( + '"color":"#f00"', + ); +}); + +test("Sass compiles what the project's PostCSS produced, as Expo's web transform orders them", () => { + // The project's PostCSS plugin appends `.from-postcss { color: $brand }`, which only Sass resolves. + expect( + rulesFor(injectedStylesheet(STYLESHEET_PATH), "from-postcss"), + ).toContain('"color":"#f00"'); +}); + +test("the stylesheet's own output is emptied and the injection carries the platform", () => { + expect( + recordedTransforms.filter(({ filePath }) => filePath === STYLESHEET_PATH), + ).toStrictEqual([]); + expect( + recordedTransforms.find( + ({ filePath }) => filePath === `${STYLESHEET_PATH}.js`, + )?.platform, + ).toBe("android"); +}); + +test("a web build of the stylesheet is the worker's, untouched", async () => { + const source = readFileSync(STYLESHEET_PATH); + const webResponse = (await transform( + CONFIG, + PROJECT_ROOT, + STYLESHEET_PATH, + source, + { + ...NATIVE_OPTIONS, + platform: "web", + }, + )) as StylesheetTransformResponse; + + const webTransform = recordedTransforms.find( + ({ filePath, platform }) => + filePath === STYLESHEET_PATH && platform === "web", + ); + expect(webTransform?.source).toBe(source.toString()); + expect(webResponse.output[0].data.css).toBeDefined(); +}); diff --git a/src/metro/metro-transformer.ts b/src/metro/metro-transformer.ts index 16eb4f53..154d9297 100644 --- a/src/metro/metro-transformer.ts +++ b/src/metro/metro-transformer.ts @@ -1,4 +1,9 @@ import { unstable_transformerPath } from "@expo/metro-config"; +import { transformPostCssModule } from "@expo/metro-config/build/transform-worker/postcss"; +import { + compileSass, + matchSass, +} from "@expo/metro-config/build/transform-worker/sass"; import type { JsTransformerConfig, JsTransformOptions, @@ -15,6 +20,25 @@ const worker = // eslint-disable-next-line @typescript-eslint/no-require-imports require(unstable_transformerPath) as typeof import("metro-transform-worker"); +/** The stylesheet after the project's PostCSS and Sass, never Expo's web build of it, which lightningcss lowers for the project's browsers. */ +async function preprocessStylesheet( + projectRoot: string, + filePath: string, + source: string, +): Promise { + const { src } = await transformPostCssModule(projectRoot, { + src: source, + filename: filePath, + }); + + const syntax = matchSass(filePath); + if (!syntax) { + return src; + } + + return compileSass(projectRoot, { filename: filePath, src }, { syntax }).src; +} + export async function transform( config: JsTransformerConfig & { reactNativeCSS?: CompilerOptions | undefined; @@ -30,14 +54,11 @@ export async function transform( return worker.transform(config, projectRoot, filePath, data, options); } - const cssFile = (await worker.transform(config, projectRoot, filePath, data, { - ...options, - platform: "web", - })) as TransformResponse & { - output: [{ data: { css: { code: Buffer } } }]; - }; - - const css = cssFile.output[0].data.css.code.toString(); + const css = await preprocessStylesheet( + projectRoot, + filePath, + data.toString("utf8"), + ); const productionJS = compile(css, { ...config.reactNativeCSS, diff --git a/yarn.lock b/yarn.lock index ced6f69e..15a2e12b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4186,6 +4186,140 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-android-arm64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-android-arm64@npm:2.6.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-darwin-arm64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-darwin-arm64@npm:2.6.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-darwin-x64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-darwin-x64@npm:2.6.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-freebsd-x64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-freebsd-x64@npm:2.6.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm-glibc@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm-glibc@npm:2.6.0" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm-musl@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm-musl@npm:2.6.0" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm64-glibc@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.6.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm64-musl@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm64-musl@npm:2.6.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-linux-x64-glibc@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-x64-glibc@npm:2.6.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-x64-musl@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-x64-musl@npm:2.6.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-win32-arm64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-win32-arm64@npm:2.6.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-win32-x64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-win32-x64@npm:2.6.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher@npm:^2.4.1": + version: 2.6.0 + resolution: "@parcel/watcher@npm:2.6.0" + dependencies: + "@parcel/watcher-android-arm64": "npm:2.6.0" + "@parcel/watcher-darwin-arm64": "npm:2.6.0" + "@parcel/watcher-darwin-x64": "npm:2.6.0" + "@parcel/watcher-freebsd-x64": "npm:2.6.0" + "@parcel/watcher-linux-arm-glibc": "npm:2.6.0" + "@parcel/watcher-linux-arm-musl": "npm:2.6.0" + "@parcel/watcher-linux-arm64-glibc": "npm:2.6.0" + "@parcel/watcher-linux-arm64-musl": "npm:2.6.0" + "@parcel/watcher-linux-x64-glibc": "npm:2.6.0" + "@parcel/watcher-linux-x64-musl": "npm:2.6.0" + "@parcel/watcher-win32-arm64": "npm:2.6.0" + "@parcel/watcher-win32-x64": "npm:2.6.0" + detect-libc: "npm:^2.0.3" + is-glob: "npm:^4.0.3" + node-addon-api: "npm:^7.0.0" + node-gyp: "npm:latest" + picomatch: "npm:^4.0.4" + dependenciesMeta: + "@parcel/watcher-android-arm64": + optional: true + "@parcel/watcher-darwin-arm64": + optional: true + "@parcel/watcher-darwin-x64": + optional: true + "@parcel/watcher-freebsd-x64": + optional: true + "@parcel/watcher-linux-arm-glibc": + optional: true + "@parcel/watcher-linux-arm-musl": + optional: true + "@parcel/watcher-linux-arm64-glibc": + optional: true + "@parcel/watcher-linux-arm64-musl": + optional: true + "@parcel/watcher-linux-x64-glibc": + optional: true + "@parcel/watcher-linux-x64-musl": + optional: true + "@parcel/watcher-win32-arm64": + optional: true + "@parcel/watcher-win32-x64": + optional: true + checksum: 10c0/0d6799bdf7d59a1ab5e4a79859035f387a66078586d9543eef0b9bd01c9d61cebe3b64eb96f0bd1f8bf50f07ed7266a1628604b66dc63025a3cee19be2e60d65 + languageName: node + linkType: hard + "@phun-ky/typeof@npm:2.0.3": version: 2.0.3 resolution: "@phun-ky/typeof@npm:2.0.3" @@ -8603,6 +8737,13 @@ __metadata: languageName: node linkType: hard +"immutable@npm:^5.1.5": + version: 5.1.9 + resolution: "immutable@npm:5.1.9" + checksum: 10c0/ae7032b60b81b682f3e7e4df13e1ec9a401a603eb81b15bb3e37331ed6666e318c71994c6936e1462eb443d32d04769396562a7e2669da22457c8ba279c03ad2 + languageName: node + linkType: hard + "import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": version: 3.3.1 resolution: "import-fresh@npm:3.3.1" @@ -11170,6 +11311,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^7.0.0": + version: 7.1.1 + resolution: "node-addon-api@npm:7.1.1" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/fb32a206276d608037fa1bcd7e9921e177fe992fc610d098aa3128baca3c0050fc1e014fa007e9b3874cf865ddb4f5bd9f43ccb7cbbbe4efaff6a83e920b17e9 + languageName: node + linkType: hard + "node-fetch-native@npm:^1.6.6": version: 1.6.7 resolution: "node-fetch-native@npm:1.6.7" @@ -12207,6 +12357,7 @@ __metadata: react-refresh: "npm:^0.17.0" react-test-renderer: "npm:19.2.3" release-it: "npm:^20.2.1" + sass: "npm:^1.106.1" tailwindcss: "npm:^4.1.12" tailwindcss-safe-area: "npm:^1.1.0" typescript: "npm:~6.0.3" @@ -12750,6 +12901,23 @@ __metadata: languageName: node linkType: hard +"sass@npm:^1.106.1": + version: 1.106.1 + resolution: "sass@npm:1.106.1" + dependencies: + "@parcel/watcher": "npm:^2.4.1" + chokidar: "npm:^5.0.0" + immutable: "npm:^5.1.5" + source-map-js: "npm:>=0.6.2 <2.0.0" + dependenciesMeta: + "@parcel/watcher": + optional: true + bin: + sass: sass.js + checksum: 10c0/4f1043e2d5b3b7e9cf563050040ed3d91ef1bf8eb9dd180ecfd7ec403bfbd52ac2f557d7e7b398573021f9240a134a080d11af21b802724c05cad386b768df4c + languageName: node + linkType: hard + "sax@npm:>=0.6.0": version: 1.4.1 resolution: "sax@npm:1.4.1" @@ -13017,6 +13185,13 @@ __metadata: languageName: node linkType: hard +"source-map-js@npm:>=0.6.2 <2.0.0": + version: 1.2.2 + resolution: "source-map-js@npm:1.2.2" + checksum: 10c0/d5d6dec62266834f83dd4f8a2606a196fb1a006ca5d53b0ef80ee513b7eba2f4e2ae241f4bc129ae7e9b80dea24f6e3d92b446b7e6e96be7653cab8888168a94 + languageName: node + linkType: hard + "source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1"