@wix/zero-config-implementation 1.95.0 → 1.97.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 +7087 -7053
- package/package.json +2 -2
- package/src/converters/a11y-builder.test.ts +32 -0
- package/src/converters/a11y-builder.ts +16 -10
- package/src/converters/to-editor-component.test.ts +115 -5
- package/src/converters/to-editor-component.ts +7 -11
- package/src/information-extractors/css/parse.test.ts +17 -1
- package/src/information-extractors/css/parse.ts +19 -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.97.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": "0b3ebaeffed6f192d5ccd84f8598e0e524b0edb36eb3ca0961bab4c7"
|
|
110
110
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { DATA } from '@wix/react-component-schema'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import type { PropWriteInfo, TrackingStores } from '../information-extractors/react/types'
|
|
4
|
+
import { collectUsedA11yAttributes } from './a11y-builder'
|
|
5
|
+
|
|
6
|
+
function propUsagesFor(propPaths: string[]): TrackingStores['propUsages'] {
|
|
7
|
+
const emptyWriteInfo = (): PropWriteInfo => ({ elements: new Map(), attributes: new Map() })
|
|
8
|
+
return new Map(propPaths.map((propPath) => [propPath, emptyWriteInfo()]))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('collectUsedA11yAttributes', () => {
|
|
12
|
+
it('returns an empty array when propUsages or propPath is unavailable', () => {
|
|
13
|
+
expect(collectUsedA11yAttributes(undefined, 'props.a11y')).toEqual([])
|
|
14
|
+
expect(collectUsedA11yAttributes(propUsagesFor(['props.a11y.role']), undefined)).toEqual([])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('returns an empty array when no accessibility fields are recorded', () => {
|
|
18
|
+
expect(collectUsedA11yAttributes(propUsagesFor([]), 'props.a11y')).toEqual([])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('allows only the current editor-configurable fields', () => {
|
|
22
|
+
const allAttributes = Object.values(DATA.A11Y_ATTRIBUTES).map((attribute) => `props.a11y.${attribute}`)
|
|
23
|
+
|
|
24
|
+
expect(collectUsedA11yAttributes(propUsagesFor(allAttributes), 'props.a11y')).toEqual(['ariaLabel', 'tag'])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('keeps allowed fields in schema order', () => {
|
|
28
|
+
const propUsages = propUsagesFor(['props.a11y.tag', 'props.a11y.role', 'props.a11y.ariaLabel'])
|
|
29
|
+
|
|
30
|
+
expect(collectUsedA11yAttributes(propUsages, 'props.a11y')).toEqual(['ariaLabel', 'tag'])
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
import { DATA } from '@wix/react-component-schema'
|
|
2
2
|
import type { TrackingStores } from '../information-extractors/react'
|
|
3
3
|
|
|
4
|
+
type A11yAttribute = (typeof DATA.A11Y_ATTRIBUTES)[keyof typeof DATA.A11Y_ATTRIBUTES]
|
|
5
|
+
|
|
6
|
+
const EDITOR_CONFIGURABLE_A11Y_ATTRIBUTES: ReadonlySet<A11yAttribute> = new Set([
|
|
7
|
+
DATA.A11Y_ATTRIBUTES.ariaLabel,
|
|
8
|
+
DATA.A11Y_ATTRIBUTES.tag,
|
|
9
|
+
])
|
|
10
|
+
|
|
4
11
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* unavailable, returns an empty array — per the schema, an empty list means
|
|
9
|
-
* "all possible A11Y values", which is a safe default.
|
|
12
|
+
* Returns the editor-configurable attributes the component reads, in schema
|
|
13
|
+
* order. The caller must omit the a11y data item when this returns an empty
|
|
14
|
+
* list because the manifest schema interprets an empty list as all attributes.
|
|
10
15
|
*/
|
|
11
16
|
export function collectUsedA11yAttributes(
|
|
12
17
|
propUsages: TrackingStores['propUsages'] | undefined,
|
|
13
18
|
propPath: string | undefined,
|
|
14
|
-
):
|
|
19
|
+
): A11yAttribute[] {
|
|
15
20
|
if (!propUsages || !propPath) return []
|
|
16
21
|
|
|
17
|
-
const
|
|
22
|
+
const usedAttributes: A11yAttribute[] = []
|
|
18
23
|
for (const attribute of Object.values(DATA.A11Y_ATTRIBUTES)) {
|
|
19
|
-
if (attribute
|
|
24
|
+
if (!EDITOR_CONFIGURABLE_A11Y_ATTRIBUTES.has(attribute)) continue
|
|
20
25
|
if (propUsages.has(`${propPath}.${attribute}`)) {
|
|
21
|
-
|
|
26
|
+
usedAttributes.push(attribute)
|
|
22
27
|
}
|
|
23
28
|
}
|
|
24
|
-
|
|
29
|
+
|
|
30
|
+
return usedAttributes
|
|
25
31
|
}
|
|
@@ -54,6 +54,45 @@ function createComponent(rootElement: ExtractedElement): ComponentInfoWithCss {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
function createComponentWithA11yUsages(propPaths: string[]): ComponentInfoWithCss {
|
|
58
|
+
return {
|
|
59
|
+
...createComponent(createElementWithDisplayMatcher('div', matcherDataFromCss(''))),
|
|
60
|
+
props: {
|
|
61
|
+
a11y: {
|
|
62
|
+
name: 'a11y',
|
|
63
|
+
type: 'A11y',
|
|
64
|
+
required: false,
|
|
65
|
+
resolvedType: { kind: 'semantic', value: 'A11y', source: '@wix/editor-react-types' },
|
|
66
|
+
propPath: 'props.a11y',
|
|
67
|
+
logicOnly: false,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
propUsages: new Map(propPaths.map((propPath) => [propPath, { elements: new Map(), attributes: new Map() }])),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('toEditorReactComponent accessibility conversion', () => {
|
|
75
|
+
it('omits the a11y item when the component reads only component-owned fields', () => {
|
|
76
|
+
const component = createComponentWithA11yUsages(['props.a11y.role', 'props.a11y.tabIndex'])
|
|
77
|
+
|
|
78
|
+
expect(toEditorReactComponent(component).editorElement?.data?.a11y).toBeUndefined()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('emits only the allowed fields when reads are mixed', () => {
|
|
82
|
+
const component = createComponentWithA11yUsages([
|
|
83
|
+
'props.a11y.role',
|
|
84
|
+
'props.a11y.ariaLabel',
|
|
85
|
+
'props.a11y.ariaExpanded',
|
|
86
|
+
'props.a11y.tag',
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
expect(toEditorReactComponent(component).editorElement?.data?.a11y).toMatchObject({
|
|
90
|
+
dataType: 'a11y',
|
|
91
|
+
a11y: { attributes: ['ariaLabel', 'tag'] },
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
57
96
|
function createSemanticElement({
|
|
58
97
|
traceId,
|
|
59
98
|
name,
|
|
@@ -369,26 +408,36 @@ function cssInfoFromStylesheet(cssString: string): ExtractedCssInfo {
|
|
|
369
408
|
return { filePath: 'property-tokens.css', api, properties, customProperties, isCssModule: false }
|
|
370
409
|
}
|
|
371
410
|
|
|
372
|
-
function
|
|
411
|
+
function createComponentWithStylesheets(
|
|
412
|
+
cssStrings: string[],
|
|
413
|
+
matchedCustomProperties: Record<string, string> = {},
|
|
414
|
+
): ComponentInfoWithCss {
|
|
415
|
+
const matcherData: MatchedCssData = { matches: [], customProperties: matchedCustomProperties }
|
|
373
416
|
const rootElement: ExtractedElement = {
|
|
374
417
|
traceId: 'trace-root',
|
|
375
418
|
name: 'root',
|
|
376
419
|
tag: 'div',
|
|
377
420
|
attributes: {},
|
|
378
|
-
extractorData: new Map<string, unknown>(),
|
|
421
|
+
extractorData: new Map<string, unknown>([['css-matcher', matcherData]]),
|
|
379
422
|
children: [],
|
|
380
423
|
}
|
|
381
424
|
|
|
382
425
|
return {
|
|
383
|
-
componentName: '
|
|
426
|
+
componentName: 'MultiStylesheet',
|
|
384
427
|
props: {},
|
|
385
428
|
elements: [rootElement],
|
|
386
429
|
propUsages: new Map(),
|
|
387
|
-
css:
|
|
388
|
-
varUsedByTraceId: new Map(
|
|
430
|
+
css: cssStrings.map(cssInfoFromStylesheet),
|
|
431
|
+
varUsedByTraceId: new Map(
|
|
432
|
+
Object.keys(matchedCustomProperties).map((varName) => [varName, new Set(['trace-root'])]),
|
|
433
|
+
),
|
|
389
434
|
}
|
|
390
435
|
}
|
|
391
436
|
|
|
437
|
+
function createComponentWithCss(cssString: string): ComponentInfoWithCss {
|
|
438
|
+
return { ...createComponentWithStylesheets([cssString]), componentName: 'PropertyTokens' }
|
|
439
|
+
}
|
|
440
|
+
|
|
392
441
|
describe('toEditorReactComponent — @property design tokens', () => {
|
|
393
442
|
it('emits typed cssCustomProperties from @property, defaulting from initial-value with no element declaration', () => {
|
|
394
443
|
const component = createComponentWithCss(`
|
|
@@ -652,3 +701,64 @@ describe('toEditorReactComponent — ActiveItemIndex display groups', () => {
|
|
|
652
701
|
expect(editorElement?.data?.activeTab?.dataType).toBe('number')
|
|
653
702
|
})
|
|
654
703
|
})
|
|
704
|
+
|
|
705
|
+
describe('toEditorReactComponent — custom property typing across several stylesheets', () => {
|
|
706
|
+
const packageStylesheet = '@property --sdf-intensity { syntax: "<number>"; inherits: false; initial-value: 1; }'
|
|
707
|
+
const componentStylesheet = '.card { --sdf-intensity: 1; background-color: var(--sdf-intensity); }'
|
|
708
|
+
|
|
709
|
+
it('collects a @property registration from any stylesheet, whichever order they arrive in', () => {
|
|
710
|
+
for (const stylesheets of [
|
|
711
|
+
[componentStylesheet, packageStylesheet],
|
|
712
|
+
[packageStylesheet, componentStylesheet],
|
|
713
|
+
]) {
|
|
714
|
+
const customProps = toEditorReactComponent(createComponentWithStylesheets(stylesheets)).editorElement
|
|
715
|
+
?.cssCustomProperties
|
|
716
|
+
|
|
717
|
+
// Usage alone infers 'backgroundColor'.
|
|
718
|
+
expect(customProps?.['sdf-intensity']?.cssPropertyType).toBe('number')
|
|
719
|
+
}
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
it('ignores a stylesheet that never mentions the variable, either order', () => {
|
|
723
|
+
const unrelatedStylesheet = '.a { font: var(--wst-heading-1-font); }'
|
|
724
|
+
const owningStylesheet = '.b { --accent: 4px; padding: var(--accent); }'
|
|
725
|
+
|
|
726
|
+
for (const stylesheets of [
|
|
727
|
+
[unrelatedStylesheet, owningStylesheet],
|
|
728
|
+
[owningStylesheet, unrelatedStylesheet],
|
|
729
|
+
]) {
|
|
730
|
+
const customProps = toEditorReactComponent(createComponentWithStylesheets(stylesheets, { '--accent': '4px' }))
|
|
731
|
+
.editorElement?.cssCustomProperties
|
|
732
|
+
|
|
733
|
+
// Guessing from the '4px' default alone infers 'length'.
|
|
734
|
+
expect(customProps?.accent?.cssPropertyType).toBe('padding')
|
|
735
|
+
}
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
it('attributes a variable from the union of its usages, however they are split up', () => {
|
|
739
|
+
// Splitting a stylesheet in two must not make attribution more confident: one stylesheet
|
|
740
|
+
// using --accent in both `color` and `padding` cannot attribute it, and neither can two.
|
|
741
|
+
const together = ['.a { --accent: 4px; color: var(--accent); padding: var(--accent); }']
|
|
742
|
+
const splitFirst = '.a { --accent: 4px; color: var(--accent); }'
|
|
743
|
+
const splitSecond = '.b { padding: var(--accent); }'
|
|
744
|
+
|
|
745
|
+
for (const stylesheets of [together, [splitFirst, splitSecond], [splitSecond, splitFirst]]) {
|
|
746
|
+
const customProps = toEditorReactComponent(createComponentWithStylesheets(stylesheets, { '--accent': '4px' }))
|
|
747
|
+
.editorElement?.cssCustomProperties
|
|
748
|
+
|
|
749
|
+
expect(customProps?.accent?.cssPropertyType).toBe('length')
|
|
750
|
+
}
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
it('does not let an unrelated stylesheet type a variable from the default value alone', () => {
|
|
754
|
+
const unrelatedStylesheet = '.wst-heading-1 { font: var(--wst-heading-1-font); }'
|
|
755
|
+
const owningStylesheet = '.card { --background-color: #ffffff; background-color: var(--background-color); }'
|
|
756
|
+
|
|
757
|
+
const customProps = toEditorReactComponent(
|
|
758
|
+
createComponentWithStylesheets([unrelatedStylesheet, owningStylesheet], { '--background-color': '#ffffff' }),
|
|
759
|
+
).editorElement?.cssCustomProperties
|
|
760
|
+
|
|
761
|
+
// Guessing from the '#ffffff' default alone infers 'color'.
|
|
762
|
+
expect(customProps?.['background-color']?.cssPropertyType).toBe('backgroundColor')
|
|
763
|
+
})
|
|
764
|
+
})
|
|
@@ -10,13 +10,13 @@ import type {
|
|
|
10
10
|
ElementItem,
|
|
11
11
|
States,
|
|
12
12
|
} from '@wix/react-component-schema'
|
|
13
|
-
import { CSS_PROPERTIES, ELEMENTS } from '@wix/react-component-schema'
|
|
13
|
+
import { CSS_PROPERTIES, DATA, ELEMENTS } from '@wix/react-component-schema'
|
|
14
14
|
import { camelCase } from 'case-anything'
|
|
15
15
|
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,
|
|
@@ -184,6 +184,7 @@ function buildData(
|
|
|
184
184
|
|
|
185
185
|
const result = buildDataItem(prop, defaultValue, propUsages, prop.propPath)
|
|
186
186
|
if (result.isErr()) throw result.error
|
|
187
|
+
if (result.value.dataType === DATA.DATA_TYPE.a11y && !result.value.a11y?.attributes?.length) continue
|
|
187
188
|
// ActiveItemIndex props default to 0 when no default is specified
|
|
188
189
|
if (prop.activeItemIndexTarget && result.value.defaultValue === undefined) {
|
|
189
190
|
result.value.defaultValue = 0
|
|
@@ -414,20 +415,15 @@ function buildCssNumber(
|
|
|
414
415
|
}
|
|
415
416
|
}
|
|
416
417
|
|
|
417
|
-
/**
|
|
418
|
-
* Queries each CSS parser API for the property type of a variable and returns
|
|
419
|
-
* the first defined result.
|
|
420
|
-
*/
|
|
421
418
|
function getVarPropertyTypeFromCssInfos(
|
|
422
419
|
varName: string,
|
|
423
420
|
defaultValue: string,
|
|
424
421
|
cssInfos: ComponentInfoWithCss['css'],
|
|
425
422
|
): CssCustomPropertyItem['cssPropertyType'] {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
return undefined
|
|
423
|
+
const usagesAcrossStylesheets = cssInfos.flatMap((cssInfo) => cssInfo.api.getVarUsages(varName))
|
|
424
|
+
|
|
425
|
+
return (cssPropertyTypeFromUsages(usagesAcrossStylesheets) ??
|
|
426
|
+
inferCssDataType(defaultValue)) as CssCustomPropertyItem['cssPropertyType']
|
|
431
427
|
}
|
|
432
428
|
|
|
433
429
|
/**
|
|
@@ -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
|
|
|
@@ -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> {
|
|
@@ -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
|
|
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
|
|