@wix/zero-config-implementation 1.5.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.
@@ -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.5.0",
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": "79168aff8f115865bd48bf546e449528bbdac723a1e22c1085e10a6b"
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 camelCase conversion
281
+ // Wix public-schemas (Builder) types - map directly to DATA_TYPE via explicit lookup
283
282
  if (source === '@wix/public-schemas') {
284
- const dataTypeKey = camelCase(semanticValue) as keyof typeof DATA_TYPE
285
- if (dataTypeKey in DATA_TYPE) {
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
+ })
@@ -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 './component-loader'
14
+ import { findComponent, loadModule } from './module-loader'
15
15
 
16
16
  // Pipeline orchestration
17
17
  import { processComponent } from './manifest-pipeline'
@@ -22,6 +22,8 @@ export type {
22
22
  ProcessComponentResult,
23
23
  } from './manifest-pipeline'
24
24
 
25
+ export type { EditorReactComponent } from './schema'
26
+
25
27
  // Converter
26
28
  import { toEditorReactComponent } from './converters'
27
29
 
@@ -102,7 +104,7 @@ export function extractComponentManifest(
102
104
  // Surface loader error as a non-fatal error
103
105
  if (loaderError) {
104
106
  errors.push({
105
- componentName: compiledEntryPath,
107
+ componentName: componentInfo.componentName,
106
108
  phase: 'loader',
107
109
  error: loaderError,
108
110
  })
@@ -214,8 +216,8 @@ export {
214
216
  withDefectBoundary,
215
217
  } from './errors'
216
218
 
217
- /** Component loader */
218
- export { createComponentLoader } from './component-loader'
219
+ /** Module loader primitives */
220
+ export { loadModule, findComponent } from './module-loader'
219
221
 
220
222
  // ── Tier 3: Low-Level Renderer ──────────────────────────────────────────────
221
223
 
@@ -158,7 +158,19 @@ function findPropsType(
158
158
  if (!moduleSymbol) return undefined
159
159
 
160
160
  const exports = checker.getExportsOfModule(moduleSymbol)
161
- const componentSymbol = exports.find((s) => s.getName() === componentName)
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 { DATA_TYPE } = DATA
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
- const camelCaseKey = camelCase(symbolName)
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
- export const DATA = { DATA_TYPE, A11Y_ATTRIBUTES, LINK_TYPE }
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
- })