@wix/zero-config-implementation 1.95.0 → 1.96.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "registry": "https://registry.npmjs.org/",
5
5
  "access": "public"
6
6
  },
7
- "version": "1.95.0",
7
+ "version": "1.96.0",
8
8
  "description": "Core library for extracting component manifests from JS and CSS files",
9
9
  "type": "module",
10
10
  "main": "dist/index.js",
@@ -106,5 +106,5 @@
106
106
  ]
107
107
  }
108
108
  },
109
- "falconPackageHash": "831bcccde22937359efc18de9a5b8b4e52d3ca3fa6ebdbdd9bcb4a65"
109
+ "falconPackageHash": "d3a6a9c74757e90d4bf8667577b7d75107131acdedfc47058f1a69da"
110
110
  }
@@ -369,26 +369,36 @@ function cssInfoFromStylesheet(cssString: string): ExtractedCssInfo {
369
369
  return { filePath: 'property-tokens.css', api, properties, customProperties, isCssModule: false }
370
370
  }
371
371
 
372
- function createComponentWithCss(cssString: string): ComponentInfoWithCss {
372
+ function createComponentWithStylesheets(
373
+ cssStrings: string[],
374
+ matchedCustomProperties: Record<string, string> = {},
375
+ ): ComponentInfoWithCss {
376
+ const matcherData: MatchedCssData = { matches: [], customProperties: matchedCustomProperties }
373
377
  const rootElement: ExtractedElement = {
374
378
  traceId: 'trace-root',
375
379
  name: 'root',
376
380
  tag: 'div',
377
381
  attributes: {},
378
- extractorData: new Map<string, unknown>(),
382
+ extractorData: new Map<string, unknown>([['css-matcher', matcherData]]),
379
383
  children: [],
380
384
  }
381
385
 
382
386
  return {
383
- componentName: 'PropertyTokens',
387
+ componentName: 'MultiStylesheet',
384
388
  props: {},
385
389
  elements: [rootElement],
386
390
  propUsages: new Map(),
387
- css: [cssInfoFromStylesheet(cssString)],
388
- varUsedByTraceId: new Map(),
391
+ css: cssStrings.map(cssInfoFromStylesheet),
392
+ varUsedByTraceId: new Map(
393
+ Object.keys(matchedCustomProperties).map((varName) => [varName, new Set(['trace-root'])]),
394
+ ),
389
395
  }
390
396
  }
391
397
 
398
+ function createComponentWithCss(cssString: string): ComponentInfoWithCss {
399
+ return { ...createComponentWithStylesheets([cssString]), componentName: 'PropertyTokens' }
400
+ }
401
+
392
402
  describe('toEditorReactComponent — @property design tokens', () => {
393
403
  it('emits typed cssCustomProperties from @property, defaulting from initial-value with no element declaration', () => {
394
404
  const component = createComponentWithCss(`
@@ -652,3 +662,64 @@ describe('toEditorReactComponent — ActiveItemIndex display groups', () => {
652
662
  expect(editorElement?.data?.activeTab?.dataType).toBe('number')
653
663
  })
654
664
  })
665
+
666
+ describe('toEditorReactComponent — custom property typing across several stylesheets', () => {
667
+ const packageStylesheet = '@property --sdf-intensity { syntax: "<number>"; inherits: false; initial-value: 1; }'
668
+ const componentStylesheet = '.card { --sdf-intensity: 1; background-color: var(--sdf-intensity); }'
669
+
670
+ it('collects a @property registration from any stylesheet, whichever order they arrive in', () => {
671
+ for (const stylesheets of [
672
+ [componentStylesheet, packageStylesheet],
673
+ [packageStylesheet, componentStylesheet],
674
+ ]) {
675
+ const customProps = toEditorReactComponent(createComponentWithStylesheets(stylesheets)).editorElement
676
+ ?.cssCustomProperties
677
+
678
+ // Usage alone infers 'backgroundColor'.
679
+ expect(customProps?.['sdf-intensity']?.cssPropertyType).toBe('number')
680
+ }
681
+ })
682
+
683
+ it('ignores a stylesheet that never mentions the variable, either order', () => {
684
+ const unrelatedStylesheet = '.a { font: var(--wst-heading-1-font); }'
685
+ const owningStylesheet = '.b { --accent: 4px; padding: var(--accent); }'
686
+
687
+ for (const stylesheets of [
688
+ [unrelatedStylesheet, owningStylesheet],
689
+ [owningStylesheet, unrelatedStylesheet],
690
+ ]) {
691
+ const customProps = toEditorReactComponent(createComponentWithStylesheets(stylesheets, { '--accent': '4px' }))
692
+ .editorElement?.cssCustomProperties
693
+
694
+ // Guessing from the '4px' default alone infers 'length'.
695
+ expect(customProps?.accent?.cssPropertyType).toBe('padding')
696
+ }
697
+ })
698
+
699
+ it('attributes a variable from the union of its usages, however they are split up', () => {
700
+ // Splitting a stylesheet in two must not make attribution more confident: one stylesheet
701
+ // using --accent in both `color` and `padding` cannot attribute it, and neither can two.
702
+ const together = ['.a { --accent: 4px; color: var(--accent); padding: var(--accent); }']
703
+ const splitFirst = '.a { --accent: 4px; color: var(--accent); }'
704
+ const splitSecond = '.b { padding: var(--accent); }'
705
+
706
+ for (const stylesheets of [together, [splitFirst, splitSecond], [splitSecond, splitFirst]]) {
707
+ const customProps = toEditorReactComponent(createComponentWithStylesheets(stylesheets, { '--accent': '4px' }))
708
+ .editorElement?.cssCustomProperties
709
+
710
+ expect(customProps?.accent?.cssPropertyType).toBe('length')
711
+ }
712
+ })
713
+
714
+ it('does not let an unrelated stylesheet type a variable from the default value alone', () => {
715
+ const unrelatedStylesheet = '.wst-heading-1 { font: var(--wst-heading-1-font); }'
716
+ const owningStylesheet = '.card { --background-color: #ffffff; background-color: var(--background-color); }'
717
+
718
+ const customProps = toEditorReactComponent(
719
+ createComponentWithStylesheets([unrelatedStylesheet, owningStylesheet], { '--background-color': '#ffffff' }),
720
+ ).editorElement?.cssCustomProperties
721
+
722
+ // Guessing from the '#ffffff' default alone infers 'color'.
723
+ expect(customProps?.['background-color']?.cssPropertyType).toBe('backgroundColor')
724
+ })
725
+ })
@@ -16,7 +16,7 @@ import { IoError } from '../errors'
16
16
  import { buildRefElementManifestKey } from '../extensions/ref-elements/path-utils'
17
17
  import type { RefElementMatch } from '../extensions/ref-elements/types'
18
18
  import type { ComponentInfoWithCss } from '../index'
19
- import { stripVarFallback } from '../information-extractors/css/parse'
19
+ import { cssPropertyTypeFromUsages, inferCssDataType, stripVarFallback } from '../information-extractors/css/parse'
20
20
  import type { MatchedCssData, RegisteredCustomProperty } from '../information-extractors/css/types'
21
21
  import type {
22
22
  CoupledComponentInfo,
@@ -414,20 +414,15 @@ function buildCssNumber(
414
414
  }
415
415
  }
416
416
 
417
- /**
418
- * Queries each CSS parser API for the property type of a variable and returns
419
- * the first defined result.
420
- */
421
417
  function getVarPropertyTypeFromCssInfos(
422
418
  varName: string,
423
419
  defaultValue: string,
424
420
  cssInfos: ComponentInfoWithCss['css'],
425
421
  ): CssCustomPropertyItem['cssPropertyType'] {
426
- for (const cssInfo of cssInfos) {
427
- const propertyType = cssInfo.api.getVarPropertyType(varName, defaultValue)
428
- if (propertyType !== undefined) return propertyType as CssCustomPropertyItem['cssPropertyType']
429
- }
430
- return undefined
422
+ const usagesAcrossStylesheets = cssInfos.flatMap((cssInfo) => cssInfo.api.getVarUsages(varName))
423
+
424
+ return (cssPropertyTypeFromUsages(usagesAcrossStylesheets) ??
425
+ inferCssDataType(defaultValue)) as CssCustomPropertyItem['cssPropertyType']
431
426
  }
432
427
 
433
428
  /**
@@ -216,7 +216,23 @@ describe('parseCss — @property registration', () => {
216
216
 
217
217
  // Used only in background-color, which would otherwise infer 'backgroundColor';
218
218
  // the @property declaration wins.
219
- expect(api.getVarPropertyType('--sdf-intensity', '1')).toBe('number')
219
+ expect(api.getVarPropertyType('--sdf-intensity')).toBe('number')
220
+ })
221
+
222
+ it('says nothing about a variable it cannot attribute to a single known property', () => {
223
+ const unrelatedApi = parseCss('.wst-heading-1 { color: var(--wst-heading-1-color); }')
224
+
225
+ expect(unrelatedApi.getVarPropertyType('--background-color')).toBeUndefined()
226
+
227
+ const owningApi = parseCss('.card { --background-color: #ffffff; background-color: var(--background-color); }')
228
+
229
+ expect(owningApi.getVarPropertyType('--background-color')).toBe('backgroundColor')
230
+
231
+ const ambiguousApi = parseCss('.card { z-index: var(--background-color); opacity: var(--background-color); }')
232
+ const unmodelledApi = parseCss('.card { grid-template-columns: var(--background-color); }')
233
+
234
+ expect(ambiguousApi.getVarPropertyType('--background-color')).toBeUndefined()
235
+ expect(unmodelledApi.getVarPropertyType('--background-color')).toBeUndefined()
220
236
  })
221
237
  })
222
238
 
@@ -85,21 +85,13 @@ export function parseCss(cssString: string): CSSParserAPI {
85
85
  return getSelectorSpecificity(parsed)
86
86
  },
87
87
 
88
- getVarPropertyType(varName: string, defaultValue: string): string {
88
+ getVarPropertyType(varName: string): string | undefined {
89
89
  // A `@property` registration is the declared type and wins over usage inference.
90
90
  const normalizedVarName = varName.startsWith('--') ? varName : `--${varName}`
91
91
  const registered = registeredProperties.get(normalizedVarName)
92
92
  if (registered) return registered.cssPropertyType
93
93
 
94
- const usages = this.getVarUsages(varName)
95
- if (usages.length === 0) return inferCssDataType(defaultValue)
96
- const uniqueProperties = [...new Set(usages)]
97
- if (uniqueProperties.length === 1) {
98
- const camelCased = camelCase(uniqueProperties[0])
99
- const validValues = Object.values(CSS_PROPERTIES.CSS_PROPERTY_TYPE) as string[]
100
- if (validValues.includes(camelCased)) return camelCased
101
- }
102
- return inferCssDataType(defaultValue)
94
+ return cssPropertyTypeFromUsages(this.getVarUsages(varName))
103
95
  },
104
96
 
105
97
  getRegisteredCustomProperties(): Map<string, RegisteredCustomProperty> {
@@ -825,12 +817,28 @@ function matchesCssType(typeName: string, value: CssNode): boolean {
825
817
  return !lexer.matchType(typeName, value).error
826
818
  }
827
819
 
820
+ /**
821
+ * The manifest `cssPropertyType` a set of `var()` usages attributes to a custom property:
822
+ * the property name itself when every usage sits in one known CSS property, otherwise
823
+ * undefined. Callers holding several stylesheets must pass the union of their usages — a
824
+ * variable spread across two stylesheets is no more attributable than one spread across two
825
+ * rules of the same stylesheet.
826
+ */
827
+ export function cssPropertyTypeFromUsages(propertyNames: Iterable<string>): string | undefined {
828
+ const uniqueProperties = [...new Set(propertyNames)]
829
+ if (uniqueProperties.length !== 1) return undefined
830
+
831
+ const camelCasedProperty = camelCase(uniqueProperties[0])
832
+ const propertyTypes = Object.values(CSS_PROPERTIES.CSS_PROPERTY_TYPE) as string[]
833
+ return propertyTypes.includes(camelCasedProperty) ? camelCasedProperty : undefined
834
+ }
835
+
828
836
  /**
829
837
  * Infers the CSS data type category from a custom property's initial value string.
830
838
  * Uses css-tree's lexer to match the value against known CSS types.
831
839
  * Returns a value from CSS_PROPERTIES.CSS_DATA_TYPE.
832
840
  */
833
- function inferCssDataType(defaultValue: string): string {
841
+ export function inferCssDataType(defaultValue: string): string {
834
842
  const { CSS_DATA_TYPE } = CSS_PROPERTIES
835
843
 
836
844
  try {
@@ -134,16 +134,16 @@ export interface CSSParserAPI {
134
134
  getSelectorSpecificity: (selector: string) => [number, number, number] | null
135
135
 
136
136
  /**
137
- * Determines the CSS property type for a custom property.
138
- * A `@property` registration is the top-priority source (declared type). Otherwise
139
- * falls back to usage: if all usages of varName are within the same CSS property,
140
- * returns that property name; if usages differ, or if the variable is not used via
141
- * var() at all, returns the CSS data type inferred from the initial value
142
- * ('color', 'length', 'number', or 'string').
137
+ * Determines the CSS property type this one stylesheet can attribute to a custom property.
138
+ * A `@property` registration is the top-priority source (declared type). Otherwise falls back
139
+ * to usage: if every usage of varName is within the same known CSS property, returns that
140
+ * property name. Returns undefined when this stylesheet cannot attribute one it does not
141
+ * mention the variable, spreads it across several properties, or uses it in a property with
142
+ * no manifest type. Callers holding several stylesheets ask the next one, and are responsible
143
+ * for the final fallback when none can answer.
143
144
  * @param varName - The CSS variable name (with or without --)
144
- * @param defaultValue - The initial value string of the custom property
145
145
  */
146
- getVarPropertyType: (varName: string, defaultValue: string) => string
146
+ getVarPropertyType: (varName: string) => string | undefined
147
147
 
148
148
  /**
149
149
  * Returns every custom property registered via a `@property` at-rule, keyed by the
@@ -0,0 +1,177 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import ts from 'typescript'
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+ import { NotFoundError } from '../../errors'
7
+ import { extractCssImports } from './css-imports'
8
+
9
+ const temporaryDirectoryPaths: string[] = []
10
+
11
+ afterEach(() => {
12
+ for (const temporaryDirectoryPath of temporaryDirectoryPaths.splice(0)) {
13
+ fs.rmSync(temporaryDirectoryPath, { force: true, recursive: true })
14
+ }
15
+ })
16
+
17
+ function createConsumerProject(componentSource: string): { componentPath: string } {
18
+ // Node resolution reports realpaths, which on macOS differ from the `/var/...` mkdtemp returns.
19
+ const temporaryDirectoryPath = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'zero-config-css-imports-')))
20
+ temporaryDirectoryPaths.push(temporaryDirectoryPath)
21
+
22
+ const stylesPackagePath = path.join(temporaryDirectoryPath, 'node_modules', '@fixture', 'site-ui')
23
+ fs.mkdirSync(path.join(stylesPackagePath, 'dist', 'Typography'), { recursive: true })
24
+ fs.writeFileSync(
25
+ path.join(stylesPackagePath, 'package.json'),
26
+ JSON.stringify({
27
+ name: '@fixture/site-ui',
28
+ version: '1.0.0',
29
+ type: 'module',
30
+ exports: { './*.css': './dist/*/index.css', './*.scss': './dist/*/index.scss' },
31
+ }),
32
+ )
33
+ fs.writeFileSync(path.join(stylesPackagePath, 'dist', 'Typography', 'index.css'), '.title { color: red; }\n')
34
+ fs.writeFileSync(path.join(stylesPackagePath, 'dist', 'Typography', 'index.scss'), '.title { color: red; }\n')
35
+
36
+ const plainPackagePath = path.join(temporaryDirectoryPath, 'node_modules', 'plain-styles')
37
+ fs.mkdirSync(path.join(plainPackagePath, 'dist'), { recursive: true })
38
+ fs.writeFileSync(
39
+ path.join(plainPackagePath, 'package.json'),
40
+ JSON.stringify({ name: 'plain-styles', version: '1.0.0', main: 'index.js' }),
41
+ )
42
+ fs.writeFileSync(path.join(plainPackagePath, 'dist', 'theme.css'), '.plain { color: green; }\n')
43
+ fs.mkdirSync(path.join(plainPackagePath, 'dist', 'directory.css'), { recursive: true })
44
+
45
+ const linkedPackagePath = path.join(temporaryDirectoryPath, 'packages', 'linked-styles')
46
+ fs.mkdirSync(linkedPackagePath, { recursive: true })
47
+ fs.writeFileSync(
48
+ path.join(linkedPackagePath, 'package.json'),
49
+ JSON.stringify({ name: 'linked-styles', version: '1.0.0', exports: { './theme.css': './theme.css' } }),
50
+ )
51
+ fs.writeFileSync(path.join(linkedPackagePath, 'theme.css'), '.linked { color: teal; }\n')
52
+ fs.symlinkSync(linkedPackagePath, path.join(temporaryDirectoryPath, 'node_modules', 'linked-styles'), 'dir')
53
+
54
+ const componentDirectoryPath = path.join(temporaryDirectoryPath, 'src', 'components', 'heading')
55
+ fs.mkdirSync(componentDirectoryPath, { recursive: true })
56
+ fs.writeFileSync(path.join(componentDirectoryPath, 'local.css'), '.local { color: blue; }\n')
57
+
58
+ const componentPath = path.join(componentDirectoryPath, 'component.tsx')
59
+ fs.writeFileSync(componentPath, componentSource)
60
+
61
+ return { componentPath }
62
+ }
63
+
64
+ function createProgram(componentPath: string): ts.Program {
65
+ return ts.createProgram([componentPath], {
66
+ jsx: ts.JsxEmit.ReactJSX,
67
+ module: ts.ModuleKind.ESNext,
68
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
69
+ noResolve: true,
70
+ target: ts.ScriptTarget.ESNext,
71
+ })
72
+ }
73
+
74
+ describe('extractCssImports — stylesheet specifier resolution', () => {
75
+ it('resolves a bare stylesheet specifier through the package exports map', () => {
76
+ const { componentPath } = createConsumerProject(
77
+ ["import '@fixture/site-ui/Typography.css'", 'export default function Heading() {', ' return null', '}'].join(
78
+ '\n',
79
+ ),
80
+ )
81
+
82
+ const cssImportPaths = extractCssImports(createProgram(componentPath))
83
+
84
+ const packageRootPath = path.resolve(path.dirname(componentPath), '../../../node_modules/@fixture/site-ui')
85
+ expect(cssImportPaths).toEqual([path.join(packageRootPath, 'dist', 'Typography', 'index.css')])
86
+ })
87
+
88
+ it('still resolves relative stylesheet specifiers against the importing file', () => {
89
+ const { componentPath } = createConsumerProject(
90
+ ["import './local.css'", 'export default function Heading() {', ' return null', '}'].join('\n'),
91
+ )
92
+
93
+ const cssImportPaths = extractCssImports(createProgram(componentPath))
94
+
95
+ expect(cssImportPaths).toEqual([path.join(path.dirname(componentPath), 'local.css')])
96
+ })
97
+
98
+ it('resolves a bare specifier from a package that publishes no exports map', () => {
99
+ const { componentPath } = createConsumerProject(
100
+ ["import 'plain-styles/dist/theme.css'", 'export default function Heading() {', ' return null', '}'].join('\n'),
101
+ )
102
+
103
+ const cssImportPaths = extractCssImports(createProgram(componentPath))
104
+
105
+ const packageRootPath = path.resolve(path.dirname(componentPath), '../../../node_modules/plain-styles')
106
+ expect(cssImportPaths).toEqual([path.join(packageRootPath, 'dist', 'theme.css')])
107
+ })
108
+
109
+ it('resolves a bare sass specifier through the package exports map', () => {
110
+ const { componentPath } = createConsumerProject(
111
+ ["import '@fixture/site-ui/Typography.scss'", 'export default function Heading() {', ' return null', '}'].join(
112
+ '\n',
113
+ ),
114
+ )
115
+
116
+ const cssImportPaths = extractCssImports(createProgram(componentPath))
117
+
118
+ expect(cssImportPaths[0]).toMatch(/site-ui[\\/]dist[\\/]Typography[\\/]index\.scss$/)
119
+ })
120
+
121
+ it('throws when a package without an exports map has no file at the specified subpath', () => {
122
+ // Node skips the exports algorithm entirely for such packages, so resolution reports a path
123
+ // whether or not a file is there.
124
+ const { componentPath } = createConsumerProject(
125
+ ["import 'plain-styles/dist/missing.css'", 'export default function Heading() {', ' return null', '}'].join(
126
+ '\n',
127
+ ),
128
+ )
129
+
130
+ expect(() => extractCssImports(createProgram(componentPath))).toThrow(/which is not a file/)
131
+ })
132
+
133
+ it('throws when the resolved path is a directory rather than a stylesheet', () => {
134
+ const { componentPath } = createConsumerProject(
135
+ ["import 'plain-styles/dist/directory.css'", 'export default function Heading() {', ' return null', '}'].join(
136
+ '\n',
137
+ ),
138
+ )
139
+
140
+ expect(() => extractCssImports(createProgram(componentPath))).toThrow(/which is not a file/)
141
+ })
142
+
143
+ it('emits one entry for a linked package stylesheet reached both bare and relatively', () => {
144
+ const { componentPath } = createConsumerProject(
145
+ [
146
+ "import 'linked-styles/theme.css'",
147
+ "import '../../../node_modules/linked-styles/theme.css'",
148
+ 'export default function Heading() {',
149
+ ' return null',
150
+ '}',
151
+ ].join('\n'),
152
+ )
153
+
154
+ const cssImportPaths = extractCssImports(createProgram(componentPath))
155
+
156
+ const linkedPackagePath = path.resolve(path.dirname(componentPath), '../../../packages/linked-styles')
157
+ expect(cssImportPaths).toEqual([path.join(linkedPackagePath, 'theme.css')])
158
+ })
159
+
160
+ it('throws a descriptive error when a bare stylesheet specifier cannot be resolved', () => {
161
+ const { componentPath } = createConsumerProject(
162
+ [
163
+ "import '@fixture/not-installed/Typography.css'",
164
+ 'export default function Heading() {',
165
+ ' return null',
166
+ '}',
167
+ ].join('\n'),
168
+ )
169
+
170
+ const program = createProgram(componentPath)
171
+
172
+ expect(() => extractCssImports(program)).toThrow(
173
+ /Cannot resolve stylesheet import "@fixture\/not-installed\/Typography\.css"/,
174
+ )
175
+ expect(() => extractCssImports(createProgram(componentPath))).toThrow(NotFoundError)
176
+ })
177
+ })
@@ -1,7 +1,62 @@
1
+ import * as fs from 'node:fs'
1
2
  import * as path from 'node:path'
2
3
  import ts, { type Program } from 'typescript'
4
+ import { NotFoundError } from '../../errors'
5
+ import { resolveModulePathFromEntryPath } from '../../extensions/shared/module-resolution'
3
6
 
4
7
  const MAX_DEPTH = 100
8
+ const STYLESHEET_EXTENSIONS = ['.css', '.scss', '.sass']
9
+
10
+ function isStylesheetSpecifier(importPath: string): boolean {
11
+ return STYLESHEET_EXTENSIONS.some((extension) => importPath.endsWith(extension))
12
+ }
13
+
14
+ function isPathSpecifier(importPath: string): boolean {
15
+ return importPath.startsWith('./') || importPath.startsWith('../') || path.isAbsolute(importPath)
16
+ }
17
+
18
+ function isFile(filePath: string): boolean {
19
+ try {
20
+ return fs.statSync(filePath).isFile()
21
+ } catch {
22
+ return false
23
+ }
24
+ }
25
+
26
+ // Node resolution reports realpaths, so path specifiers must be normalised the same way for
27
+ // `cssFiles` to dedupe a stylesheet reached both ways.
28
+ function toRealPath(filePath: string): string {
29
+ try {
30
+ return fs.realpathSync(filePath)
31
+ } catch {
32
+ return filePath
33
+ }
34
+ }
35
+
36
+ function resolveStylesheetPath(importPath: string, importingFileName: string): string {
37
+ if (isPathSpecifier(importPath)) {
38
+ return toRealPath(path.resolve(path.dirname(importingFileName), importPath))
39
+ }
40
+
41
+ const packageResolvedPath = resolveModulePathFromEntryPath(importingFileName, importPath)
42
+ if (!packageResolvedPath) {
43
+ throw new NotFoundError(
44
+ `Cannot resolve stylesheet import "${importPath}" from "${importingFileName}". Ensure the package is installed and that its "exports" map covers this subpath.`,
45
+ { props: { phase: 'css' } },
46
+ )
47
+ }
48
+
49
+ // A package with no `exports` map resolves by plain path join, so Node reports a path whether
50
+ // or not a file is there. Reading it is the only thing that settles it.
51
+ if (!isFile(packageResolvedPath)) {
52
+ throw new NotFoundError(
53
+ `Stylesheet import "${importPath}" from "${importingFileName}" resolves to "${packageResolvedPath}", which is not a file.`,
54
+ { props: { phase: 'css' } },
55
+ )
56
+ }
57
+
58
+ return toRealPath(packageResolvedPath)
59
+ }
5
60
 
6
61
  export function extractCssImports(program: Program): string[] {
7
62
  const cssFiles = new Set<string>()
@@ -16,9 +71,8 @@ export function extractCssImports(program: Program): string[] {
16
71
  if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) return
17
72
  const importPath = node.moduleSpecifier.text
18
73
 
19
- if (importPath.endsWith('.css') || importPath.endsWith('.scss') || importPath.endsWith('.sass')) {
20
- const fullPath = path.resolve(path.dirname(sourceFile.fileName), importPath)
21
- cssFiles.add(fullPath)
74
+ if (isStylesheetSpecifier(importPath)) {
75
+ cssFiles.add(resolveStylesheetPath(importPath, sourceFile.fileName))
22
76
  return
23
77
  }
24
78