@wix/zero-config-implementation 1.94.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/dist/index.d.ts +8 -8
- package/dist/index.js +20020 -19972
- package/package.json +2 -2
- package/src/converters/to-editor-component.test.ts +99 -5
- package/src/converters/to-editor-component.ts +7 -10
- package/src/information-extractors/css/parse.test.ts +36 -2
- package/src/information-extractors/css/parse.ts +47 -11
- package/src/information-extractors/css/types.ts +8 -8
- package/src/information-extractors/ts/css-imports.test.ts +177 -0
- package/src/information-extractors/ts/css-imports.ts +57 -3
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"registry": "https://registry.npmjs.org/",
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"version": "1.
|
|
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": "
|
|
109
|
+
"falconPackageHash": "d3a6a9c74757e90d4bf8667577b7d75107131acdedfc47058f1a69da"
|
|
110
110
|
}
|
|
@@ -263,6 +263,29 @@ describe('toEditorReactComponent display conversion', () => {
|
|
|
263
263
|
})
|
|
264
264
|
})
|
|
265
265
|
|
|
266
|
+
describe('toEditorReactComponent — cssProperties defaultValue', () => {
|
|
267
|
+
it('ignores the var() fallback when building a regular CSS property defaultValue', () => {
|
|
268
|
+
const extractorData = new Map<string, unknown>()
|
|
269
|
+
extractorData.set('css-matcher', matcherDataFromCss('background: var(wst-primary-background-color, tomato);'))
|
|
270
|
+
extractorData.set('css-properties', { relevant: [CSS_PROPERTIES.CSS_PROPERTY_TYPE.background] })
|
|
271
|
+
|
|
272
|
+
const rootElement: ExtractedElement = {
|
|
273
|
+
traceId: 'trace-root',
|
|
274
|
+
name: 'root',
|
|
275
|
+
tag: 'div',
|
|
276
|
+
attributes: {},
|
|
277
|
+
extractorData,
|
|
278
|
+
children: [],
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const result = toEditorReactComponent(createComponent(rootElement))
|
|
282
|
+
|
|
283
|
+
expect(result.editorElement?.cssProperties?.background).toEqual({
|
|
284
|
+
defaultValue: 'var(--wst-primary-background-color)',
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
266
289
|
describe('toEditorReactComponent selector conversion', () => {
|
|
267
290
|
it.each([
|
|
268
291
|
['a', 'a___'],
|
|
@@ -346,26 +369,36 @@ function cssInfoFromStylesheet(cssString: string): ExtractedCssInfo {
|
|
|
346
369
|
return { filePath: 'property-tokens.css', api, properties, customProperties, isCssModule: false }
|
|
347
370
|
}
|
|
348
371
|
|
|
349
|
-
function
|
|
372
|
+
function createComponentWithStylesheets(
|
|
373
|
+
cssStrings: string[],
|
|
374
|
+
matchedCustomProperties: Record<string, string> = {},
|
|
375
|
+
): ComponentInfoWithCss {
|
|
376
|
+
const matcherData: MatchedCssData = { matches: [], customProperties: matchedCustomProperties }
|
|
350
377
|
const rootElement: ExtractedElement = {
|
|
351
378
|
traceId: 'trace-root',
|
|
352
379
|
name: 'root',
|
|
353
380
|
tag: 'div',
|
|
354
381
|
attributes: {},
|
|
355
|
-
extractorData: new Map<string, unknown>(),
|
|
382
|
+
extractorData: new Map<string, unknown>([['css-matcher', matcherData]]),
|
|
356
383
|
children: [],
|
|
357
384
|
}
|
|
358
385
|
|
|
359
386
|
return {
|
|
360
|
-
componentName: '
|
|
387
|
+
componentName: 'MultiStylesheet',
|
|
361
388
|
props: {},
|
|
362
389
|
elements: [rootElement],
|
|
363
390
|
propUsages: new Map(),
|
|
364
|
-
css:
|
|
365
|
-
varUsedByTraceId: new Map(
|
|
391
|
+
css: cssStrings.map(cssInfoFromStylesheet),
|
|
392
|
+
varUsedByTraceId: new Map(
|
|
393
|
+
Object.keys(matchedCustomProperties).map((varName) => [varName, new Set(['trace-root'])]),
|
|
394
|
+
),
|
|
366
395
|
}
|
|
367
396
|
}
|
|
368
397
|
|
|
398
|
+
function createComponentWithCss(cssString: string): ComponentInfoWithCss {
|
|
399
|
+
return { ...createComponentWithStylesheets([cssString]), componentName: 'PropertyTokens' }
|
|
400
|
+
}
|
|
401
|
+
|
|
369
402
|
describe('toEditorReactComponent — @property design tokens', () => {
|
|
370
403
|
it('emits typed cssCustomProperties from @property, defaulting from initial-value with no element declaration', () => {
|
|
371
404
|
const component = createComponentWithCss(`
|
|
@@ -629,3 +662,64 @@ describe('toEditorReactComponent — ActiveItemIndex display groups', () => {
|
|
|
629
662
|
expect(editorElement?.data?.activeTab?.dataType).toBe('number')
|
|
630
663
|
})
|
|
631
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,6 +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 { cssPropertyTypeFromUsages, inferCssDataType, stripVarFallback } from '../information-extractors/css/parse'
|
|
19
20
|
import type { MatchedCssData, RegisteredCustomProperty } from '../information-extractors/css/types'
|
|
20
21
|
import type {
|
|
21
22
|
CoupledComponentInfo,
|
|
@@ -413,20 +414,15 @@ function buildCssNumber(
|
|
|
413
414
|
}
|
|
414
415
|
}
|
|
415
416
|
|
|
416
|
-
/**
|
|
417
|
-
* Queries each CSS parser API for the property type of a variable and returns
|
|
418
|
-
* the first defined result.
|
|
419
|
-
*/
|
|
420
417
|
function getVarPropertyTypeFromCssInfos(
|
|
421
418
|
varName: string,
|
|
422
419
|
defaultValue: string,
|
|
423
420
|
cssInfos: ComponentInfoWithCss['css'],
|
|
424
421
|
): CssCustomPropertyItem['cssPropertyType'] {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
return undefined
|
|
422
|
+
const usagesAcrossStylesheets = cssInfos.flatMap((cssInfo) => cssInfo.api.getVarUsages(varName))
|
|
423
|
+
|
|
424
|
+
return (cssPropertyTypeFromUsages(usagesAcrossStylesheets) ??
|
|
425
|
+
inferCssDataType(defaultValue)) as CssCustomPropertyItem['cssPropertyType']
|
|
430
426
|
}
|
|
431
427
|
|
|
432
428
|
/**
|
|
@@ -580,7 +576,8 @@ function buildCssProperties(element: ExtractedElement | undefined): Record<strin
|
|
|
580
576
|
result[propName] = buildDisplayProperty(element)
|
|
581
577
|
continue
|
|
582
578
|
}
|
|
583
|
-
const
|
|
579
|
+
const resolvedValue = resolveLonghand(propName, cssPropertyValues)
|
|
580
|
+
const defaultValue = resolvedValue !== undefined ? stripVarFallback(resolvedValue) : undefined
|
|
584
581
|
result[propName] = {
|
|
585
582
|
...(defaultValue !== undefined && { defaultValue }),
|
|
586
583
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { parseCss, resolveCssPropertyValue } from './parse'
|
|
2
|
+
import { parseCss, resolveCssPropertyValue, stripVarFallback } from './parse'
|
|
3
3
|
|
|
4
4
|
describe('parseCss — getStateClasses', () => {
|
|
5
5
|
it('extracts a custom-class modifier with no paired pseudo', () => {
|
|
@@ -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'
|
|
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
|
|
|
@@ -325,3 +341,21 @@ describe('resolveVarIdentifiers — CSS Modules @value compatibility', () => {
|
|
|
325
341
|
expect(contentProperty.value).toBe('"var(foo)"')
|
|
326
342
|
})
|
|
327
343
|
})
|
|
344
|
+
|
|
345
|
+
describe('stripVarFallback', () => {
|
|
346
|
+
it('removes a var() fallback, keeping only the variable reference', () => {
|
|
347
|
+
expect(stripVarFallback('var(--wst-primary-background-color, tomato)')).toBe('var(--wst-primary-background-color)')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('leaves a var() with no fallback unchanged', () => {
|
|
351
|
+
expect(stripVarFallback('var(--x)')).toBe('var(--x)')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('strips a fallback embedded within a larger value', () => {
|
|
355
|
+
expect(stripVarFallback('1px solid var(--border-color, gray)')).toBe('1px solid var(--border-color)')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('leaves a value with no var() unchanged', () => {
|
|
359
|
+
expect(stripVarFallback('10px')).toBe('10px')
|
|
360
|
+
})
|
|
361
|
+
})
|
|
@@ -85,21 +85,13 @@ export function parseCss(cssString: string): CSSParserAPI {
|
|
|
85
85
|
return getSelectorSpecificity(parsed)
|
|
86
86
|
},
|
|
87
87
|
|
|
88
|
-
getVarPropertyType(varName: 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
|
-
|
|
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> {
|
|
@@ -373,6 +365,34 @@ function resolveVarIdentifiers(valueNode: CssNode, valueAliases: Map<string, str
|
|
|
373
365
|
return generate(valueNode)
|
|
374
366
|
}
|
|
375
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Removes the fallback argument from every var() call in a CSS value string. A var()
|
|
370
|
+
* fallback is not a meaningful "default value" for the editor to surface — only the
|
|
371
|
+
* variable reference itself is — so `var(--x, tomato)` becomes `var(--x)`.
|
|
372
|
+
*/
|
|
373
|
+
export function stripVarFallback(cssValue: string): string {
|
|
374
|
+
if (!cssValue.includes('var(')) return cssValue
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
const valueAst = parse(cssValue, { context: 'value' })
|
|
378
|
+
walk(valueAst, {
|
|
379
|
+
visit: 'Function',
|
|
380
|
+
enter(functionNode: FunctionNode) {
|
|
381
|
+
if (functionNode.name !== 'var') return
|
|
382
|
+
const functionArguments = [...functionNode.children]
|
|
383
|
+
const firstCommaIndex = functionArguments.findIndex(
|
|
384
|
+
(argumentNode) => argumentNode.type === 'Operator' && argumentNode.value === ',',
|
|
385
|
+
)
|
|
386
|
+
if (firstCommaIndex === -1) return
|
|
387
|
+
functionNode.children.fromArray(functionArguments.slice(0, firstCommaIndex))
|
|
388
|
+
},
|
|
389
|
+
})
|
|
390
|
+
return generate(valueAst)
|
|
391
|
+
} catch {
|
|
392
|
+
return cssValue
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
376
396
|
/**
|
|
377
397
|
* Extracts all CSS custom property names referenced via var() by walking
|
|
378
398
|
* the declaration value AST for Function nodes named "var".
|
|
@@ -797,12 +817,28 @@ function matchesCssType(typeName: string, value: CssNode): boolean {
|
|
|
797
817
|
return !lexer.matchType(typeName, value).error
|
|
798
818
|
}
|
|
799
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
|
+
|
|
800
836
|
/**
|
|
801
837
|
* Infers the CSS data type category from a custom property's initial value string.
|
|
802
838
|
* Uses css-tree's lexer to match the value against known CSS types.
|
|
803
839
|
* Returns a value from CSS_PROPERTIES.CSS_DATA_TYPE.
|
|
804
840
|
*/
|
|
805
|
-
function inferCssDataType(defaultValue: string): string {
|
|
841
|
+
export function inferCssDataType(defaultValue: string): string {
|
|
806
842
|
const { CSS_DATA_TYPE } = CSS_PROPERTIES
|
|
807
843
|
|
|
808
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
|
|
138
|
-
* A `@property` registration is the top-priority source (declared type). Otherwise
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
|
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 (
|
|
20
|
-
|
|
21
|
-
cssFiles.add(fullPath)
|
|
74
|
+
if (isStylesheetSpecifier(importPath)) {
|
|
75
|
+
cssFiles.add(resolveStylesheetPath(importPath, sourceFile.fileName))
|
|
22
76
|
return
|
|
23
77
|
}
|
|
24
78
|
|