@lorion-org/react 1.0.0-beta.1 → 1.0.0-beta.2

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/src/vite.ts ADDED
@@ -0,0 +1,445 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join, relative, resolve, sep } from 'node:path';
3
+ import process from 'node:process';
4
+ import {
5
+ createCompositionSelection,
6
+ createDescriptorCatalog,
7
+ resolveDescriptorSelectionSeed,
8
+ type CompositionPolicy,
9
+ type Descriptor,
10
+ type DescriptorId,
11
+ type DescriptorSelectionSeedInput,
12
+ type RelationDescriptor,
13
+ } from '@lorion-org/composition-graph';
14
+ import {
15
+ descriptorSchema,
16
+ discoverDescriptors,
17
+ type DiscoveredDescriptor,
18
+ } from '@lorion-org/descriptor-discovery';
19
+ import {
20
+ collectSelectedProviderPreferences,
21
+ resolveSelectedProviderRelationPreferences,
22
+ type ProviderPreferenceMap,
23
+ } from '@lorion-org/provider-selection';
24
+ import {
25
+ createCapabilityCompositionPolicy,
26
+ defaultCapabilityRelationDescriptors,
27
+ } from './relations';
28
+
29
+ const virtualModuleId = 'virtual:capabilities';
30
+ const resolvedVirtualModuleId = `\0${virtualModuleId}`;
31
+
32
+ export type CapabilityLoaderOptions = {
33
+ capabilitiesDir?: string;
34
+ baseDescriptors?: readonly DescriptorId[];
35
+ defaultSelection?: readonly DescriptorId[];
36
+ policy?: Partial<CompositionPolicy>;
37
+ relationDescriptors?: readonly RelationDescriptor[];
38
+ selected?: readonly DescriptorId[];
39
+ selectionSeed?: false | CapabilitySelectionSeedOptions;
40
+ workspaceRoot?: string;
41
+ };
42
+
43
+ export type CapabilitySelectionSeedOptions = Omit<DescriptorSelectionSeedInput, 'defaultValue'>;
44
+
45
+ export type CapabilityRouteConfigOptions = CapabilityLoaderOptions & {
46
+ indexRouteFile?: false | string;
47
+ routesDirectory: string;
48
+ workspaceRoot: string;
49
+ };
50
+
51
+ export type LorionReactViteOptions = CapabilityRouteConfigOptions;
52
+
53
+ export type LorionReactViteSetup = {
54
+ capabilityLoader: VitePlugin;
55
+ routeConfig: VirtualRootRoute;
56
+ };
57
+
58
+ export type DiscoveredCapability = {
59
+ disabled: boolean;
60
+ entryFile: string;
61
+ id: string;
62
+ importSpecifier: string;
63
+ manifest: Descriptor;
64
+ packageName: string;
65
+ routesDirectory?: string;
66
+ variableName: string;
67
+ };
68
+
69
+ export type VirtualIndexRoute = {
70
+ file: string;
71
+ type: 'index';
72
+ };
73
+
74
+ export type VirtualPhysicalRouteSubtree = {
75
+ directory: string;
76
+ pathPrefix: string;
77
+ type: 'physical';
78
+ };
79
+
80
+ export type VirtualRootRoute = {
81
+ children: Array<VirtualIndexRoute | VirtualPhysicalRouteSubtree>;
82
+ file: string;
83
+ type: 'root';
84
+ };
85
+
86
+ export type ViteResolvedConfig = {
87
+ root: string;
88
+ };
89
+
90
+ export type VitePlugin = {
91
+ configResolved: (resolvedConfig: ViteResolvedConfig) => void;
92
+ enforce: 'pre';
93
+ load: (id: string) => string | null;
94
+ name: string;
95
+ resolveId: (id: string) => string | undefined;
96
+ };
97
+
98
+ export function capabilityLoader(options: CapabilityLoaderOptions = {}): VitePlugin {
99
+ let config: ViteResolvedConfig;
100
+ let capabilities: DiscoveredCapability[] = [];
101
+
102
+ return {
103
+ name: 'lorion-react-capability-loader',
104
+ enforce: 'pre',
105
+ configResolved(resolvedConfig) {
106
+ config = resolvedConfig;
107
+ capabilities = discoverSelectedCapabilities(
108
+ resolveWorkspaceRoot(config.root, options),
109
+ options,
110
+ );
111
+ },
112
+ resolveId(id) {
113
+ if (id === virtualModuleId) return resolvedVirtualModuleId;
114
+
115
+ return capabilities.find((capability) => capability.importSpecifier === id)?.entryFile;
116
+ },
117
+ load(id) {
118
+ if (id !== resolvedVirtualModuleId) return null;
119
+
120
+ return renderCapabilityModule(capabilities, resolveSelectionSeed(options));
121
+ },
122
+ };
123
+ }
124
+
125
+ export function lorionReact(options: LorionReactViteOptions): LorionReactViteSetup {
126
+ return {
127
+ capabilityLoader: capabilityLoader(options),
128
+ routeConfig: createCapabilityRouteConfig(options),
129
+ };
130
+ }
131
+
132
+ export function discoverCapabilities(
133
+ workspaceRoot: string,
134
+ options: Pick<CapabilityLoaderOptions, 'capabilitiesDir'> = {},
135
+ ): DiscoveredCapability[] {
136
+ const capabilitiesRoot = resolve(workspaceRoot, options.capabilitiesDir ?? 'capabilities');
137
+
138
+ if (!existsSync(capabilitiesRoot)) {
139
+ throw new Error(`Capabilities directory not found: ${capabilitiesRoot}`);
140
+ }
141
+
142
+ return discoverCapabilityDescriptors(workspaceRoot, options)
143
+ .map(discoverCapability)
144
+ .sort((left, right) => left.id.localeCompare(right.id));
145
+ }
146
+
147
+ export function discoverSelectedCapabilities(
148
+ workspaceRoot: string,
149
+ options: CapabilityLoaderOptions = {},
150
+ ): DiscoveredCapability[] {
151
+ return selectCapabilities(discoverCapabilities(workspaceRoot, options), options);
152
+ }
153
+
154
+ function selectCapabilities(
155
+ capabilities: readonly DiscoveredCapability[],
156
+ options: Pick<
157
+ CapabilityLoaderOptions,
158
+ | 'baseDescriptors'
159
+ | 'defaultSelection'
160
+ | 'policy'
161
+ | 'relationDescriptors'
162
+ | 'selected'
163
+ | 'selectionSeed'
164
+ > = {},
165
+ ): DiscoveredCapability[] {
166
+ const enabledCapabilities = capabilities.filter((capability) => capability.disabled !== true);
167
+ const selected = resolveCapabilitySelectionSeed(options);
168
+
169
+ if (!selected.length && !options.baseDescriptors?.length) {
170
+ return [...enabledCapabilities];
171
+ }
172
+
173
+ const selectedProviders = collectSelectedProviderPreferences({
174
+ items: enabledCapabilities,
175
+ getCapabilityId: (capability) => capability.manifest.providesFor,
176
+ getProviderId: (capability) => capability.id,
177
+ selectedProviderIds: selected,
178
+ });
179
+ const selectionCapabilities = createProviderSelectionAwareCapabilities(
180
+ enabledCapabilities,
181
+ selectedProviders,
182
+ );
183
+ const catalog = createDescriptorCatalog({
184
+ descriptors: selectionCapabilities.map((capability) => capability.manifest),
185
+ relationDescriptors: [
186
+ ...defaultCapabilityRelationDescriptors,
187
+ ...(options.relationDescriptors ?? []),
188
+ ],
189
+ });
190
+ const selection = createCompositionSelection({
191
+ catalog,
192
+ selected: [...selected],
193
+ baseDescriptors: [...(options.baseDescriptors ?? [])],
194
+ policy: createCapabilityCompositionPolicy(options.policy),
195
+ });
196
+ const selectedIds = new Set(selection.getResolved());
197
+
198
+ return selectionCapabilities.filter((capability) => selectedIds.has(capability.id));
199
+ }
200
+
201
+ function createProviderSelectionAwareCapabilities(
202
+ capabilities: readonly DiscoveredCapability[],
203
+ selectedProviders: ProviderPreferenceMap,
204
+ ): DiscoveredCapability[] {
205
+ if (!Object.keys(selectedProviders).length) return [...capabilities];
206
+
207
+ return capabilities.map((capability) => {
208
+ const manifest = { ...capability.manifest };
209
+ const preferences = resolveSelectedProviderRelationPreferences({
210
+ providerId: capability.id,
211
+ defaultFor: manifest.defaultFor,
212
+ providerPreferences: manifest.providerPreferences as ProviderPreferenceMap | undefined,
213
+ selectedProviders,
214
+ });
215
+
216
+ delete manifest.defaultFor;
217
+ delete manifest.providerPreferences;
218
+
219
+ return {
220
+ ...capability,
221
+ manifest: {
222
+ ...manifest,
223
+ ...preferences,
224
+ },
225
+ };
226
+ });
227
+ }
228
+
229
+ function resolveSelectionSeed(
230
+ options: Pick<CapabilityLoaderOptions, 'defaultSelection' | 'selected' | 'selectionSeed'>,
231
+ ): DescriptorId[] {
232
+ return resolveCapabilitySelectionSeed(options);
233
+ }
234
+
235
+ function resolveCapabilitySelectionSeed(
236
+ options: Pick<CapabilityLoaderOptions, 'defaultSelection' | 'selected' | 'selectionSeed'>,
237
+ ): DescriptorId[] {
238
+ if (options.selected?.length) return [...options.selected];
239
+
240
+ if (options.selectionSeed === false) return [...(options.defaultSelection ?? [])];
241
+
242
+ const seedOptions = options.selectionSeed ?? {};
243
+ const selected = resolveDescriptorSelectionSeed({
244
+ argv: seedOptions.argv ?? process.argv,
245
+ env: seedOptions.env ?? process.env,
246
+ key: seedOptions.key ?? 'capability',
247
+ ...(seedOptions.cliKeys ? { cliKeys: seedOptions.cliKeys } : {}),
248
+ ...(seedOptions.envKeys ? { envKeys: seedOptions.envKeys } : {}),
249
+ });
250
+
251
+ return selected.length ? selected : [...(options.defaultSelection ?? [])];
252
+ }
253
+
254
+ export function renderCapabilityModule(
255
+ capabilities: readonly DiscoveredCapability[],
256
+ selected: readonly DescriptorId[] = [],
257
+ ): string {
258
+ const imports = capabilities
259
+ .map(
260
+ (capability) =>
261
+ `import { capability as ${capability.variableName} } from '${capability.importSpecifier}'`,
262
+ )
263
+ .join('\n');
264
+ const variables = capabilities.map((capability) => ` ${capability.variableName},`).join('\n');
265
+ const capabilityIds = capabilities.map((capability) => capability.id);
266
+
267
+ return `${imports}
268
+
269
+ export const selectedCapabilityIds = ${JSON.stringify([...selected])}
270
+
271
+ export const resolvedCapabilityIds = ${JSON.stringify(capabilityIds)}
272
+
273
+ export const capabilityModules = [
274
+ ${variables}
275
+ ]
276
+ `;
277
+ }
278
+
279
+ export function createCapabilityRouteConfig(
280
+ options: CapabilityRouteConfigOptions,
281
+ ): VirtualRootRoute {
282
+ if (!options?.workspaceRoot) {
283
+ throw new Error('createCapabilityRouteConfig requires a workspaceRoot option.');
284
+ }
285
+
286
+ if (!options?.routesDirectory) {
287
+ throw new Error('createCapabilityRouteConfig requires a routesDirectory option.');
288
+ }
289
+
290
+ const routesDirectory = resolve(options.routesDirectory);
291
+ const capabilities = discoverSelectedCapabilities(options.workspaceRoot, options);
292
+ const capabilityRouteSubtrees = capabilities
293
+ .filter(hasRouteDirectory)
294
+ .filter((capability) => capability.disabled !== true)
295
+ .map((capability) => ({
296
+ type: 'physical' as const,
297
+ pathPrefix: '',
298
+ directory: toPosixPath(relative(routesDirectory, capability.routesDirectory)),
299
+ }));
300
+
301
+ return {
302
+ type: 'root',
303
+ file: '__root.tsx',
304
+ children: [
305
+ ...(options.indexRouteFile === false
306
+ ? []
307
+ : [
308
+ {
309
+ type: 'index' as const,
310
+ file: options.indexRouteFile ?? 'index.tsx',
311
+ },
312
+ ]),
313
+ ...capabilityRouteSubtrees,
314
+ ],
315
+ };
316
+ }
317
+
318
+ function discoverCapabilityDescriptors(
319
+ workspaceRoot: string,
320
+ options: Pick<CapabilityLoaderOptions, 'capabilitiesDir'>,
321
+ ): DiscoveredDescriptor[] {
322
+ const capabilitiesDir = options.capabilitiesDir ?? 'capabilities';
323
+
324
+ return discoverDescriptors({
325
+ cwd: workspaceRoot,
326
+ descriptorPaths: [`${capabilitiesDir}/*/capability.json`],
327
+ validation: {
328
+ schema: descriptorSchema,
329
+ },
330
+ });
331
+ }
332
+
333
+ function discoverCapability(entry: DiscoveredDescriptor): DiscoveredCapability {
334
+ const capabilityDir = entry.cwd;
335
+ const packagePath = resolve(capabilityDir, 'package.json');
336
+
337
+ if (!existsSync(packagePath)) {
338
+ throw new Error(
339
+ `Capability must define both capability.json and package.json: ${capabilityDir}`,
340
+ );
341
+ }
342
+
343
+ const packageJson = readJson(packagePath);
344
+ const packageName = packageJson.name;
345
+
346
+ if (typeof packageName !== 'string') {
347
+ throw new Error(`Capability package is missing "name": ${packagePath}`);
348
+ }
349
+
350
+ const activationEntry = resolveActivationEntry(capabilityDir, packageJson);
351
+ const routesDirectory = resolveRouteDirectory(capabilityDir);
352
+
353
+ return {
354
+ id: entry.descriptor.id,
355
+ disabled: entry.descriptor.disabled === true,
356
+ entryFile: activationEntry.entryFile,
357
+ importSpecifier: activationEntry.importSpecifier,
358
+ manifest: entry.descriptor,
359
+ packageName,
360
+ ...(routesDirectory ? { routesDirectory } : {}),
361
+ variableName: toVariableName(entry.descriptor.id),
362
+ };
363
+ }
364
+
365
+ function resolveRouteDirectory(capabilityDir: string): string | undefined {
366
+ const routesDirectory = resolve(capabilityDir, 'src', 'routes');
367
+
368
+ return existsSync(routesDirectory) ? routesDirectory : undefined;
369
+ }
370
+
371
+ function resolveActivationEntry(
372
+ capabilityDir: string,
373
+ packageJson: Record<string, unknown>,
374
+ ): { entryFile: string; importSpecifier: string } {
375
+ const packageName = packageJson.name;
376
+ const packageExports = packageJson.exports;
377
+
378
+ if (typeof packageName !== 'string') {
379
+ throw new Error(
380
+ `Capability package is missing "name": ${resolve(capabilityDir, 'package.json')}`,
381
+ );
382
+ }
383
+
384
+ if (!isRecord(packageExports) || typeof packageExports['./capability'] !== 'string') {
385
+ throw new Error(`Capability package is missing a "./capability" export: ${capabilityDir}`);
386
+ }
387
+
388
+ return {
389
+ entryFile: resolve(capabilityDir, packageExports['./capability']),
390
+ importSpecifier: `${packageName}/capability`,
391
+ };
392
+ }
393
+
394
+ function hasRouteDirectory(
395
+ capability: DiscoveredCapability,
396
+ ): capability is DiscoveredCapability & { routesDirectory: string } {
397
+ return typeof capability.routesDirectory === 'string';
398
+ }
399
+
400
+ function isRecord(value: unknown): value is Record<string, unknown> {
401
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
402
+ }
403
+
404
+ function resolveWorkspaceRoot(configRoot: string, options: CapabilityLoaderOptions): string {
405
+ if (options.workspaceRoot) return resolve(options.workspaceRoot);
406
+
407
+ return findWorkspaceRoot(configRoot);
408
+ }
409
+
410
+ function findWorkspaceRoot(startDir: string): string {
411
+ let current = resolve(startDir);
412
+
413
+ while (true) {
414
+ if (
415
+ existsSync(join(current, 'pnpm-workspace.yaml')) &&
416
+ existsSync(join(current, 'capabilities'))
417
+ ) {
418
+ return current;
419
+ }
420
+
421
+ const parent = dirname(current);
422
+
423
+ if (parent === current) {
424
+ throw new Error(`Could not find React workspace root from: ${startDir}`);
425
+ }
426
+
427
+ current = parent;
428
+ }
429
+ }
430
+
431
+ function readJson(path: string): Record<string, unknown> {
432
+ return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
433
+ }
434
+
435
+ function toVariableName(name: string): string {
436
+ return `${name
437
+ .replace(/[^a-zA-Z0-9]+/g, ' ')
438
+ .trim()
439
+ .replace(/(?:^|\s)([a-zA-Z0-9])/g, (_, char: string) => char.toUpperCase())
440
+ .replace(/^([A-Z])/, (char) => char.toLowerCase())}Capability`;
441
+ }
442
+
443
+ function toPosixPath(path: string): string {
444
+ return path.split(sep).join('/');
445
+ }