@wix/zero-config-implementation 1.6.0 → 1.7.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/converters/utils.d.ts +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +15076 -29328
- package/dist/module-loader.d.ts +20 -0
- package/dist/schema.d.ts +1 -0
- package/package.json +2 -5
- package/src/converters/data-item-builder.ts +4 -5
- package/src/converters/utils.test.ts +16 -0
- package/src/converters/utils.ts +8 -0
- package/src/index.ts +4 -4
- package/src/information-extractors/ts/components.ts +13 -1
- package/src/information-extractors/ts/utils/semantic-type-resolver.ts +2 -4
- package/src/module-loader.ts +76 -0
- package/src/schema.ts +33 -1
- package/vite.config.ts +6 -0
- package/dist/component-loader.d.ts +0 -42
- package/src/component-loader.test.ts +0 -277
- package/src/component-loader.ts +0 -256
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ResultAsync } from 'neverthrow';
|
|
2
|
+
import { ComponentType } from 'react';
|
|
3
|
+
import { IoError } from './errors';
|
|
4
|
+
type IoErrorInstance = InstanceType<typeof IoError>;
|
|
5
|
+
/**
|
|
6
|
+
* Attempts to load a module, first via ESM `import()`, then via CJS `require`.
|
|
7
|
+
*
|
|
8
|
+
* ESM `import()` is preferred because it participates in the ESM loader hook
|
|
9
|
+
* pipeline (registered via `module.register()` in the CLI). This is required
|
|
10
|
+
* for JSX interception to work — the loader hook redirects `react/jsx-runtime`
|
|
11
|
+
* to the interceptable version. CJS `require()` bypasses ESM hooks even when
|
|
12
|
+
* loading ESM modules (Node 22+), so it's only used as a fallback.
|
|
13
|
+
*
|
|
14
|
+
* @param entryPath - Absolute path to the module entry point.
|
|
15
|
+
* @returns A `ResultAsync` containing the module exports on success.
|
|
16
|
+
* @errors {IoError} When both ESM import and CJS require fail.
|
|
17
|
+
*/
|
|
18
|
+
export declare function loadModule(entryPath: string): ResultAsync<Record<string, unknown>, IoErrorInstance>;
|
|
19
|
+
export declare function findComponent(moduleExports: Record<string, unknown>, name: string): ComponentType<unknown> | null;
|
|
20
|
+
export {};
|
package/dist/schema.d.ts
CHANGED
|
@@ -77,6 +77,7 @@ export declare const DATA: {
|
|
|
77
77
|
readonly edgeAnchorLinks: "edgeAnchorLinks";
|
|
78
78
|
readonly loginToWixLink: "loginToWixLink";
|
|
79
79
|
};
|
|
80
|
+
WIX_TYPE_TO_DATA_TYPE: Record<string, "number" | "function" | "UNKNOWN_DataType" | "text" | "textEnum" | "booleanValue" | "a11y" | "link" | "image" | "video" | "vectorArt" | "audio" | "schema" | "localDate" | "localTime" | "localDateTime" | "webUrl" | "email" | "phone" | "hostname" | "regex" | "guid" | "richText" | "container" | "arrayItems" | "direction" | "menuItems" | "data" | "onClick" | "onChange" | "onKeyPress" | "onKeyUp" | "onSubmit">;
|
|
80
81
|
};
|
|
81
82
|
export declare const MEDIA: {
|
|
82
83
|
VIDEO_CATEGORY: {
|
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.7.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",
|
|
@@ -50,14 +50,11 @@
|
|
|
50
50
|
"modern-errors-cli": "^6.0.0",
|
|
51
51
|
"neverthrow": "^8.2.0",
|
|
52
52
|
"parse5": "^7.1.2",
|
|
53
|
-
"pkg-entry-points": "^1.1.1",
|
|
54
53
|
"react-docgen-typescript": "^2.4.0",
|
|
55
|
-
"read-package-up": "^11.0.0",
|
|
56
54
|
"tsconfck": "^3.1.6",
|
|
57
55
|
"typedoc": "^0.28.16",
|
|
58
56
|
"typedoc-plugin-markdown": "^4.9.0",
|
|
59
57
|
"vite": "^7.3.1",
|
|
60
|
-
"vite-node": "^5.3.0",
|
|
61
58
|
"vite-plugin-dts": "^4.5.4",
|
|
62
59
|
"vitest": "^4.0.17"
|
|
63
60
|
},
|
|
@@ -77,5 +74,5 @@
|
|
|
77
74
|
]
|
|
78
75
|
}
|
|
79
76
|
},
|
|
80
|
-
"falconPackageHash": "
|
|
77
|
+
"falconPackageHash": "95c3bb44ab2a1406f75946ef5d30cec9f4ce31059113151e31289d75"
|
|
81
78
|
}
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
* resolved type metadata is inconsistent (kind === 'array' but no elementType).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { camelCase } from 'case-anything'
|
|
16
15
|
import type { Result } from 'neverthrow'
|
|
17
16
|
import { err, ok } from 'neverthrow'
|
|
18
17
|
import { ParseError } from '../errors'
|
|
@@ -21,7 +20,7 @@ import type { DataItem } from '../schema'
|
|
|
21
20
|
import { DATA, MEDIA } from '../schema'
|
|
22
21
|
import { formatDisplayName } from './utils'
|
|
23
22
|
|
|
24
|
-
const { DATA_TYPE } = DATA
|
|
23
|
+
const { DATA_TYPE, WIX_TYPE_TO_DATA_TYPE } = DATA
|
|
25
24
|
|
|
26
25
|
type ParseErrorInstance = InstanceType<typeof ParseError>
|
|
27
26
|
|
|
@@ -279,10 +278,10 @@ function handleSemanticType(dataItem: DataItem, resolvedType: ResolvedType): voi
|
|
|
279
278
|
return
|
|
280
279
|
}
|
|
281
280
|
|
|
282
|
-
// Wix public-schemas (Builder) types - map directly to DATA_TYPE via
|
|
281
|
+
// Wix public-schemas (Builder) types - map directly to DATA_TYPE via explicit lookup
|
|
283
282
|
if (source === '@wix/public-schemas') {
|
|
284
|
-
const dataTypeKey =
|
|
285
|
-
if (dataTypeKey
|
|
283
|
+
const dataTypeKey = WIX_TYPE_TO_DATA_TYPE[semanticValue]
|
|
284
|
+
if (dataTypeKey) {
|
|
286
285
|
dataItem.dataType = DATA_TYPE[dataTypeKey]
|
|
287
286
|
applyDataToBuilderType(dataItem, dataTypeKey)
|
|
288
287
|
} else {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { formatDisplayName } from './utils'
|
|
3
|
+
|
|
4
|
+
describe('formatDisplayName', () => {
|
|
5
|
+
it('maps a11y to Accessibility', () => {
|
|
6
|
+
expect(formatDisplayName('a11y')).toBe('Accessibility')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('formats kebab-case', () => {
|
|
10
|
+
expect(formatDisplayName('input-field-weight')).toBe('Input Field Weight')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('formats camelCase', () => {
|
|
14
|
+
expect(formatDisplayName('camelCaseExample')).toBe('Camel Case Example')
|
|
15
|
+
})
|
|
16
|
+
})
|
package/src/converters/utils.ts
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
import { capitalCase } from 'case-anything'
|
|
6
6
|
|
|
7
|
+
const DISPLAY_NAME_OVERRIDES = {
|
|
8
|
+
a11y: 'Accessibility',
|
|
9
|
+
} as const
|
|
10
|
+
|
|
7
11
|
/**
|
|
8
12
|
* Formats any string format to Title Case display name
|
|
9
13
|
* Handles: kebab-case, camelCase, PascalCase, snake_case, SCREAMING_SNAKE_CASE, and mixed formats
|
|
@@ -15,7 +19,11 @@ import { capitalCase } from 'case-anything'
|
|
|
15
19
|
* "snake_case_example" -> "Snake Case Example"
|
|
16
20
|
* "SCREAMING_SNAKE_CASE" -> "Screaming Snake Case"
|
|
17
21
|
* "mixed-format_example" -> "Mixed Format Example"
|
|
22
|
+
* "a11y" -> "Accessibility"
|
|
18
23
|
*/
|
|
19
24
|
export function formatDisplayName(input: string): string {
|
|
25
|
+
if (input in DISPLAY_NAME_OVERRIDES) {
|
|
26
|
+
return DISPLAY_NAME_OVERRIDES[input as keyof typeof DISPLAY_NAME_OVERRIDES]
|
|
27
|
+
}
|
|
20
28
|
return capitalCase(input, { keepSpecialCharacters: false })
|
|
21
29
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { compileTsFile } from './ts-compiler'
|
|
|
11
11
|
import { type NotFoundError, ParseError } from './errors'
|
|
12
12
|
|
|
13
13
|
// Component loader helpers
|
|
14
|
-
import { findComponent, loadModule } from './
|
|
14
|
+
import { findComponent, loadModule } from './module-loader'
|
|
15
15
|
|
|
16
16
|
// Pipeline orchestration
|
|
17
17
|
import { processComponent } from './manifest-pipeline'
|
|
@@ -104,7 +104,7 @@ export function extractComponentManifest(
|
|
|
104
104
|
// Surface loader error as a non-fatal error
|
|
105
105
|
if (loaderError) {
|
|
106
106
|
errors.push({
|
|
107
|
-
componentName:
|
|
107
|
+
componentName: componentInfo.componentName,
|
|
108
108
|
phase: 'loader',
|
|
109
109
|
error: loaderError,
|
|
110
110
|
})
|
|
@@ -216,8 +216,8 @@ export {
|
|
|
216
216
|
withDefectBoundary,
|
|
217
217
|
} from './errors'
|
|
218
218
|
|
|
219
|
-
/**
|
|
220
|
-
export {
|
|
219
|
+
/** Module loader primitives */
|
|
220
|
+
export { loadModule, findComponent } from './module-loader'
|
|
221
221
|
|
|
222
222
|
// ── Tier 3: Low-Level Renderer ──────────────────────────────────────────────
|
|
223
223
|
|
|
@@ -158,7 +158,19 @@ function findPropsType(
|
|
|
158
158
|
if (!moduleSymbol) return undefined
|
|
159
159
|
|
|
160
160
|
const exports = checker.getExportsOfModule(moduleSymbol)
|
|
161
|
-
|
|
161
|
+
let componentSymbol = exports.find((s) => s.getName() === componentName)
|
|
162
|
+
|
|
163
|
+
// If not found by name, check whether the default export resolves to the component
|
|
164
|
+
if (!componentSymbol) {
|
|
165
|
+
const defaultSymbol = exports.find((s) => s.getName() === 'default')
|
|
166
|
+
if (defaultSymbol && (defaultSymbol.getFlags() & ts.SymbolFlags.Alias) !== 0) {
|
|
167
|
+
const resolved = checker.getAliasedSymbol(defaultSymbol)
|
|
168
|
+
if (resolved.getName() === componentName) {
|
|
169
|
+
componentSymbol = resolved
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
162
174
|
if (!componentSymbol) return undefined
|
|
163
175
|
|
|
164
176
|
const componentType = checker.getTypeOfSymbol(componentSymbol)
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import * as fs from 'node:fs'
|
|
2
2
|
import * as path from 'node:path'
|
|
3
|
-
import { camelCase } from 'case-anything'
|
|
4
3
|
import ts from 'typescript'
|
|
5
4
|
import { DATA } from '../../../schema'
|
|
6
5
|
import type { PropInfo, ResolvedType } from '../types'
|
|
7
6
|
|
|
8
7
|
const MAX_RESOLVE_DEPTH = 30
|
|
9
8
|
|
|
10
|
-
const {
|
|
9
|
+
const { WIX_TYPE_TO_DATA_TYPE } = DATA
|
|
11
10
|
|
|
12
11
|
/**
|
|
13
12
|
* Cache for package name lookups
|
|
@@ -50,8 +49,7 @@ const VALID_REACT_SEMANTIC_TYPES = new Set(['ReactNode', 'ReactElement'])
|
|
|
50
49
|
* Checks if a Wix type name maps to a valid DATA_TYPE key
|
|
51
50
|
*/
|
|
52
51
|
function isValidWixSemanticType(symbolName: string): boolean {
|
|
53
|
-
|
|
54
|
-
return camelCaseKey in DATA_TYPE
|
|
52
|
+
return symbolName in WIX_TYPE_TO_DATA_TYPE
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
/**
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import { pascalCase } from 'case-anything'
|
|
3
|
+
import { ResultAsync, errAsync, okAsync } from 'neverthrow'
|
|
4
|
+
import type { ComponentType } from 'react'
|
|
5
|
+
import { IoError } from './errors'
|
|
6
|
+
|
|
7
|
+
type IoErrorInstance = InstanceType<typeof IoError>
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Attempts to load a module, first via ESM `import()`, then via CJS `require`.
|
|
11
|
+
*
|
|
12
|
+
* ESM `import()` is preferred because it participates in the ESM loader hook
|
|
13
|
+
* pipeline (registered via `module.register()` in the CLI). This is required
|
|
14
|
+
* for JSX interception to work — the loader hook redirects `react/jsx-runtime`
|
|
15
|
+
* to the interceptable version. CJS `require()` bypasses ESM hooks even when
|
|
16
|
+
* loading ESM modules (Node 22+), so it's only used as a fallback.
|
|
17
|
+
*
|
|
18
|
+
* @param entryPath - Absolute path to the module entry point.
|
|
19
|
+
* @returns A `ResultAsync` containing the module exports on success.
|
|
20
|
+
* @errors {IoError} When both ESM import and CJS require fail.
|
|
21
|
+
*/
|
|
22
|
+
export function loadModule(entryPath: string): ResultAsync<Record<string, unknown>, IoErrorInstance> {
|
|
23
|
+
return ResultAsync.fromPromise(import(entryPath) as Promise<Record<string, unknown>>, () => null).orElse(() => {
|
|
24
|
+
try {
|
|
25
|
+
const require = createRequire(import.meta.url)
|
|
26
|
+
const exports = require(entryPath)
|
|
27
|
+
return okAsync(exports as Record<string, unknown>)
|
|
28
|
+
} catch (requireErr) {
|
|
29
|
+
const cause = requireErr instanceof Error ? requireErr : new Error(String(requireErr))
|
|
30
|
+
return errAsync(
|
|
31
|
+
new IoError(`Failed to load ${entryPath}: ${cause.message}`, {
|
|
32
|
+
cause,
|
|
33
|
+
props: { phase: 'load' },
|
|
34
|
+
}),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isPascalCase(name: string): boolean {
|
|
41
|
+
return name.length > 0 && pascalCase(name) === name
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isComponent(value: unknown): value is ComponentType<unknown> {
|
|
45
|
+
if (typeof value === 'function') return isPascalCase(value.name)
|
|
46
|
+
// React.memo() and React.forwardRef() return objects, not functions
|
|
47
|
+
if (typeof value === 'object' && value !== null && '$$typeof' in value) return true
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function findComponent(moduleExports: Record<string, unknown>, name: string): ComponentType<unknown> | null {
|
|
52
|
+
// Direct named export
|
|
53
|
+
const direct = moduleExports[name]
|
|
54
|
+
if ((typeof direct === 'function' && isPascalCase(name)) || isComponent(direct)) {
|
|
55
|
+
return direct as ComponentType<unknown>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check default export
|
|
59
|
+
const defaultExport = moduleExports.default
|
|
60
|
+
if (defaultExport) {
|
|
61
|
+
// default is the component itself (matches by function name)
|
|
62
|
+
if (typeof defaultExport === 'function' && defaultExport.name === name) {
|
|
63
|
+
return defaultExport as ComponentType<unknown>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// CJS interop: default is an object with named exports
|
|
67
|
+
if (typeof defaultExport === 'object' && defaultExport !== null) {
|
|
68
|
+
const nested = (defaultExport as Record<string, unknown>)[name]
|
|
69
|
+
if (isComponent(nested)) {
|
|
70
|
+
return nested as ComponentType<unknown>
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null
|
|
76
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -116,7 +116,39 @@ const ELEMENT_TYPE = {
|
|
|
116
116
|
refElement: 'refElement',
|
|
117
117
|
} as const
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Maps @wix/public-schemas type names (PascalCase) to their DATA_TYPE keys.
|
|
121
|
+
* Derived from the exports of @wix/public-schemas.
|
|
122
|
+
*/
|
|
123
|
+
const WIX_TYPE_TO_DATA_TYPE: Record<string, keyof typeof DATA_TYPE> = {
|
|
124
|
+
Link: 'link',
|
|
125
|
+
Image: 'image',
|
|
126
|
+
Video: 'video',
|
|
127
|
+
VectorArt: 'vectorArt',
|
|
128
|
+
A11y: 'a11y',
|
|
129
|
+
Audio: 'audio',
|
|
130
|
+
MenuItems: 'menuItems',
|
|
131
|
+
Schema: 'schema',
|
|
132
|
+
Text: 'text',
|
|
133
|
+
TextEnum: 'textEnum',
|
|
134
|
+
NumberType: 'number',
|
|
135
|
+
BooleanValue: 'booleanValue',
|
|
136
|
+
LocalDate: 'localDate',
|
|
137
|
+
LocalTime: 'localTime',
|
|
138
|
+
LocalDateTime: 'localDateTime',
|
|
139
|
+
WebUrl: 'webUrl',
|
|
140
|
+
Email: 'email',
|
|
141
|
+
Phone: 'phone',
|
|
142
|
+
Hostname: 'hostname',
|
|
143
|
+
Regex: 'regex',
|
|
144
|
+
Guid: 'guid',
|
|
145
|
+
RichText: 'richText',
|
|
146
|
+
Container: 'container',
|
|
147
|
+
ArrayItems: 'arrayItems',
|
|
148
|
+
Direction: 'direction',
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const DATA = { DATA_TYPE, A11Y_ATTRIBUTES, LINK_TYPE, WIX_TYPE_TO_DATA_TYPE }
|
|
120
152
|
export const MEDIA = { VIDEO_CATEGORY, VECTOR_ART_CATEGORY, IMAGE_CATEGORY }
|
|
121
153
|
export const ELEMENTS = { ELEMENT_TYPE }
|
|
122
154
|
|
package/vite.config.ts
CHANGED
|
@@ -25,6 +25,12 @@ export default defineConfig({
|
|
|
25
25
|
if (id.startsWith('node:') || builtinModules.includes(id) || ['typescript', 'lightningcss'].includes(id)) {
|
|
26
26
|
return true
|
|
27
27
|
}
|
|
28
|
+
// Externalize React so the host project's React instance is used at runtime.
|
|
29
|
+
// Bundling React creates a second instance that causes hook failures when
|
|
30
|
+
// user components resolve a different React from their own node_modules.
|
|
31
|
+
if (id === 'react' || id === 'react-dom' || id.startsWith('react/') || id.startsWith('react-dom/')) {
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
28
34
|
return false
|
|
29
35
|
},
|
|
30
36
|
},
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { ResultAsync } from 'neverthrow';
|
|
2
|
-
import { ComponentType } from 'react';
|
|
3
|
-
import { IoError, NotFoundError } from './errors';
|
|
4
|
-
type NotFoundErrorInstance = InstanceType<typeof NotFoundError>;
|
|
5
|
-
type IoErrorInstance = InstanceType<typeof IoError>;
|
|
6
|
-
export type LoaderError = NotFoundErrorInstance | IoErrorInstance;
|
|
7
|
-
export interface ComponentLoaderResult {
|
|
8
|
-
loadComponent: (name: string) => ComponentType<unknown> | null;
|
|
9
|
-
packageName: string;
|
|
10
|
-
entryPath: string | undefined;
|
|
11
|
-
exportNames: string[];
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Creates a component loader that dynamically loads components from the
|
|
15
|
-
* user's package. Finds the nearest package.json, resolves its entry point,
|
|
16
|
-
* and loads the module.
|
|
17
|
-
*
|
|
18
|
-
* The returned `loadComponent` is synchronous (required by processComponent).
|
|
19
|
-
* ESM modules are loaded via async `import()` during creation, so this
|
|
20
|
-
* function itself is async.
|
|
21
|
-
*
|
|
22
|
-
* @returns A `ResultAsync` containing a `ComponentLoaderResult` on success.
|
|
23
|
-
* @errors {NotFoundError} When no package.json is found or the package has no resolvable entry points.
|
|
24
|
-
* @errors {IoError} When module loading fails (CJS require or ESM import).
|
|
25
|
-
*/
|
|
26
|
-
export declare function createComponentLoader(componentPath: string): ResultAsync<ComponentLoaderResult, LoaderError>;
|
|
27
|
-
/**
|
|
28
|
-
* Attempts to load a module, first via ESM `import()`, then via CJS `require`.
|
|
29
|
-
*
|
|
30
|
-
* ESM `import()` is preferred because it participates in the ESM loader hook
|
|
31
|
-
* pipeline (registered via `module.register()` in the CLI). This is required
|
|
32
|
-
* for JSX interception to work — the loader hook redirects `react/jsx-runtime`
|
|
33
|
-
* to the interceptable version. CJS `require()` bypasses ESM hooks even when
|
|
34
|
-
* loading ESM modules (Node 22+), so it's only used as a fallback.
|
|
35
|
-
*
|
|
36
|
-
* @param entryPath - Absolute path to the module entry point.
|
|
37
|
-
* @returns A `ResultAsync` containing the module exports on success.
|
|
38
|
-
* @errors {IoError} When both ESM import and CJS require fail.
|
|
39
|
-
*/
|
|
40
|
-
export declare function loadModule(entryPath: string): ResultAsync<Record<string, unknown>, IoErrorInstance>;
|
|
41
|
-
export declare function findComponent(moduleExports: Record<string, unknown>, name: string): ComponentType<unknown> | null;
|
|
42
|
-
export {};
|
|
@@ -1,277 +0,0 @@
|
|
|
1
|
-
import { join } from 'node:path'
|
|
2
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
-
|
|
4
|
-
const mockModuleRequire = vi.fn()
|
|
5
|
-
|
|
6
|
-
vi.mock('node:module', () => ({
|
|
7
|
-
createRequire: () => mockModuleRequire,
|
|
8
|
-
}))
|
|
9
|
-
|
|
10
|
-
vi.mock('read-package-up', () => ({
|
|
11
|
-
readPackageUpSync: vi.fn(),
|
|
12
|
-
}))
|
|
13
|
-
|
|
14
|
-
vi.mock('pkg-entry-points', () => ({
|
|
15
|
-
getPackageEntryPoints: vi.fn(),
|
|
16
|
-
}))
|
|
17
|
-
|
|
18
|
-
import { getPackageEntryPoints } from 'pkg-entry-points'
|
|
19
|
-
import type { NormalizedPackageJson } from 'read-package-up'
|
|
20
|
-
import { readPackageUpSync } from 'read-package-up'
|
|
21
|
-
import { createComponentLoader } from './component-loader'
|
|
22
|
-
|
|
23
|
-
const mockReadPackageUp = vi.mocked(readPackageUpSync)
|
|
24
|
-
const mockGetEntryPoints = vi.mocked(getPackageEntryPoints)
|
|
25
|
-
|
|
26
|
-
const fakePkgDir = '/fake/project'
|
|
27
|
-
const fakePkgJsonPath = join(fakePkgDir, 'package.json')
|
|
28
|
-
|
|
29
|
-
function mockPackage(name: string) {
|
|
30
|
-
mockReadPackageUp.mockReturnValue({
|
|
31
|
-
packageJson: { name } as NormalizedPackageJson,
|
|
32
|
-
path: fakePkgJsonPath,
|
|
33
|
-
})
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
beforeEach(() => {
|
|
37
|
-
vi.resetAllMocks()
|
|
38
|
-
mockModuleRequire.mockImplementation(() => {
|
|
39
|
-
throw new Error('Cannot find module')
|
|
40
|
-
})
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
describe('createComponentLoader', () => {
|
|
44
|
-
it('returns error when no package.json is found', async () => {
|
|
45
|
-
mockReadPackageUp.mockReturnValue(undefined)
|
|
46
|
-
|
|
47
|
-
const result = await createComponentLoader('/some/Component.tsx')
|
|
48
|
-
|
|
49
|
-
expect(result.isErr()).toBe(true)
|
|
50
|
-
if (result.isErr()) {
|
|
51
|
-
expect(result.error.message).toBe('No package.json found')
|
|
52
|
-
}
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('returns error when package has no resolvable entry points', async () => {
|
|
56
|
-
mockPackage('my-pkg')
|
|
57
|
-
mockGetEntryPoints.mockResolvedValue({})
|
|
58
|
-
|
|
59
|
-
const result = await createComponentLoader(join(fakePkgDir, 'src/Component.tsx'))
|
|
60
|
-
|
|
61
|
-
expect(result.isErr()).toBe(true)
|
|
62
|
-
if (result.isErr()) {
|
|
63
|
-
expect(result.error.message).toContain('no resolvable entry point')
|
|
64
|
-
expect(result.error.message).toContain('my-pkg')
|
|
65
|
-
}
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('returns error when only types exports exist', async () => {
|
|
69
|
-
mockPackage('types-only')
|
|
70
|
-
mockGetEntryPoints.mockResolvedValue({
|
|
71
|
-
'.': [[['types'], './dist/index.d.ts']],
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
const result = await createComponentLoader(join(fakePkgDir, 'src/Component.tsx'))
|
|
75
|
-
|
|
76
|
-
expect(result.isErr()).toBe(true)
|
|
77
|
-
if (result.isErr()) {
|
|
78
|
-
expect(result.error.message).toContain('no loadable exports')
|
|
79
|
-
}
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it('returns error with path when single entry point fails to load', async () => {
|
|
83
|
-
mockPackage('bad-entry')
|
|
84
|
-
mockGetEntryPoints.mockResolvedValue({
|
|
85
|
-
'.': [[['import'], './dist/index.js']],
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
const result = await createComponentLoader(join(fakePkgDir, 'src/Component.tsx'))
|
|
89
|
-
|
|
90
|
-
expect(result.isErr()).toBe(true)
|
|
91
|
-
if (result.isErr()) {
|
|
92
|
-
expect(result.error.message).toContain('Failed to load')
|
|
93
|
-
expect(result.error.message).toContain(join(fakePkgDir, 'dist/index.js'))
|
|
94
|
-
}
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('returns error when multiple subpath exports all fail to load', async () => {
|
|
98
|
-
mockPackage('multi-fail')
|
|
99
|
-
mockGetEntryPoints.mockResolvedValue({
|
|
100
|
-
'./foo': [[['import'], './dist/foo.js']],
|
|
101
|
-
'./bar': [[['import'], './dist/bar.js']],
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
const result = await createComponentLoader(join(fakePkgDir, 'src/Component.tsx'))
|
|
105
|
-
|
|
106
|
-
expect(result.isErr()).toBe(true)
|
|
107
|
-
if (result.isErr()) {
|
|
108
|
-
expect(result.error.message).toContain('Failed to load')
|
|
109
|
-
}
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it('skips types-only entries in multi-subpath exports', async () => {
|
|
113
|
-
mockPackage('mixed-types')
|
|
114
|
-
mockGetEntryPoints.mockResolvedValue({
|
|
115
|
-
'./types': [[['types'], './dist/types.d.ts']],
|
|
116
|
-
'./runtime': [[['import'], './dist/does-not-exist.js']],
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
const result = await createComponentLoader(join(fakePkgDir, 'src/Component.tsx'))
|
|
120
|
-
|
|
121
|
-
// ./types should be skipped (selectBestPath returns undefined), ./runtime fails to load
|
|
122
|
-
expect(result.isErr()).toBe(true)
|
|
123
|
-
if (result.isErr()) {
|
|
124
|
-
expect(result.error.message).toContain('Failed to load')
|
|
125
|
-
}
|
|
126
|
-
})
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
describe('selectBestPath (via createComponentLoader)', () => {
|
|
130
|
-
it('prefers import condition over node and default', async () => {
|
|
131
|
-
mockPackage('prio-test')
|
|
132
|
-
mockGetEntryPoints.mockResolvedValue({
|
|
133
|
-
'.': [
|
|
134
|
-
[['default'], './dist/default.js'],
|
|
135
|
-
[['node'], './dist/node.js'],
|
|
136
|
-
[['import'], './dist/import.js'],
|
|
137
|
-
],
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
const result = await createComponentLoader(join(fakePkgDir, 'src/Component.tsx'))
|
|
141
|
-
|
|
142
|
-
// Module load will fail since these are fake paths, but entryPath is in the error
|
|
143
|
-
expect(result.isErr()).toBe(true)
|
|
144
|
-
if (result.isErr()) {
|
|
145
|
-
expect(result.error.message).toContain(join(fakePkgDir, 'dist/import.js'))
|
|
146
|
-
}
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
it('falls back to node condition when import is not available', async () => {
|
|
150
|
-
mockPackage('node-fallback')
|
|
151
|
-
mockGetEntryPoints.mockResolvedValue({
|
|
152
|
-
'.': [
|
|
153
|
-
[['default'], './dist/default.js'],
|
|
154
|
-
[['node'], './dist/node.js'],
|
|
155
|
-
],
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
const result = await createComponentLoader(join(fakePkgDir, 'src/Component.tsx'))
|
|
159
|
-
|
|
160
|
-
expect(result.isErr()).toBe(true)
|
|
161
|
-
if (result.isErr()) {
|
|
162
|
-
expect(result.error.message).toContain(join(fakePkgDir, 'dist/node.js'))
|
|
163
|
-
}
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
it('falls back to default condition when import and node are not available', async () => {
|
|
167
|
-
mockPackage('default-fallback')
|
|
168
|
-
mockGetEntryPoints.mockResolvedValue({
|
|
169
|
-
'.': [[['default'], './dist/default.js']],
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
const result = await createComponentLoader(join(fakePkgDir, 'src/Component.tsx'))
|
|
173
|
-
|
|
174
|
-
expect(result.isErr()).toBe(true)
|
|
175
|
-
if (result.isErr()) {
|
|
176
|
-
expect(result.error.message).toContain(join(fakePkgDir, 'dist/default.js'))
|
|
177
|
-
}
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
it('skips entries that include types condition', async () => {
|
|
181
|
-
mockPackage('skip-types')
|
|
182
|
-
mockGetEntryPoints.mockResolvedValue({
|
|
183
|
-
'.': [
|
|
184
|
-
[['import', 'types'], './dist/index.d.ts'],
|
|
185
|
-
[['import'], './dist/index.js'],
|
|
186
|
-
],
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
const result = await createComponentLoader(join(fakePkgDir, 'src/Component.tsx'))
|
|
190
|
-
|
|
191
|
-
expect(result.isErr()).toBe(true)
|
|
192
|
-
if (result.isErr()) {
|
|
193
|
-
expect(result.error.message).toContain(join(fakePkgDir, 'dist/index.js'))
|
|
194
|
-
}
|
|
195
|
-
})
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
describe('error message quality', () => {
|
|
199
|
-
it('includes package name in error messages', async () => {
|
|
200
|
-
mockPackage('@scope/my-components')
|
|
201
|
-
mockGetEntryPoints.mockResolvedValue({})
|
|
202
|
-
|
|
203
|
-
const result = await createComponentLoader(join(fakePkgDir, 'src/Component.tsx'))
|
|
204
|
-
|
|
205
|
-
expect(result.isErr()).toBe(true)
|
|
206
|
-
if (result.isErr()) {
|
|
207
|
-
expect(result.error.message).toContain('@scope/my-components')
|
|
208
|
-
}
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
it('includes package.json path in no-entry-point error', async () => {
|
|
212
|
-
mockPackage('no-entry')
|
|
213
|
-
mockGetEntryPoints.mockResolvedValue({})
|
|
214
|
-
|
|
215
|
-
const result = await createComponentLoader(join(fakePkgDir, 'src/Component.tsx'))
|
|
216
|
-
|
|
217
|
-
expect(result.isErr()).toBe(true)
|
|
218
|
-
if (result.isErr()) {
|
|
219
|
-
expect(result.error.message).toContain(fakePkgJsonPath)
|
|
220
|
-
}
|
|
221
|
-
})
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
describe('component resolution behavior', () => {
|
|
225
|
-
it('fails today for direct named export when function name is minified', async () => {
|
|
226
|
-
const packageDir = '/fake/minified-export-test'
|
|
227
|
-
const packageJsonPath = join(packageDir, 'package.json')
|
|
228
|
-
|
|
229
|
-
mockReadPackageUp.mockReturnValue({
|
|
230
|
-
packageJson: { name: 'minified-export-test' } as NormalizedPackageJson,
|
|
231
|
-
path: packageJsonPath,
|
|
232
|
-
})
|
|
233
|
-
mockGetEntryPoints.mockResolvedValue({
|
|
234
|
-
'.': [[['import'], './dist/index.js']],
|
|
235
|
-
})
|
|
236
|
-
mockModuleRequire.mockImplementation(() => ({
|
|
237
|
-
CardX: function j() {
|
|
238
|
-
return null
|
|
239
|
-
},
|
|
240
|
-
}))
|
|
241
|
-
|
|
242
|
-
const result = await createComponentLoader(join(packageDir, 'src/CardX.tsx'))
|
|
243
|
-
|
|
244
|
-
expect(result.isOk()).toBe(true)
|
|
245
|
-
if (result.isOk()) {
|
|
246
|
-
const component = result.value.loadComponent('CardX')
|
|
247
|
-
// TDD red phase: this currently returns null because function.name === "j".
|
|
248
|
-
expect(component).not.toBeNull()
|
|
249
|
-
}
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
it('rejects direct function exports with non-PascalCase export keys', async () => {
|
|
253
|
-
const packageDir = '/fake/lowercase-export-test'
|
|
254
|
-
const packageJsonPath = join(packageDir, 'package.json')
|
|
255
|
-
|
|
256
|
-
mockReadPackageUp.mockReturnValue({
|
|
257
|
-
packageJson: { name: 'lowercase-export-test' } as NormalizedPackageJson,
|
|
258
|
-
path: packageJsonPath,
|
|
259
|
-
})
|
|
260
|
-
mockGetEntryPoints.mockResolvedValue({
|
|
261
|
-
'.': [[['import'], './dist/index.js']],
|
|
262
|
-
})
|
|
263
|
-
mockModuleRequire.mockImplementation(() => ({
|
|
264
|
-
helper: function j() {
|
|
265
|
-
return null
|
|
266
|
-
},
|
|
267
|
-
}))
|
|
268
|
-
|
|
269
|
-
const result = await createComponentLoader(join(packageDir, 'src/helper.ts'))
|
|
270
|
-
|
|
271
|
-
expect(result.isOk()).toBe(true)
|
|
272
|
-
if (result.isOk()) {
|
|
273
|
-
const component = result.value.loadComponent('helper')
|
|
274
|
-
expect(component).toBeNull()
|
|
275
|
-
}
|
|
276
|
-
})
|
|
277
|
-
})
|