@shipfox/client-shell 1.0.0 → 3.0.1
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/assets/apple-touch-icon.png +0 -0
- package/assets/favicon-96x96.png +0 -0
- package/assets/favicon.ico +0 -0
- package/assets/favicon.svg +1 -0
- package/assets/site.webmanifest +22 -0
- package/assets/web-app-manifest-192x192.png +0 -0
- package/assets/web-app-manifest-512x512.png +0 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/dist/vite/index.d.ts +1 -0
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +1 -0
- package/dist/vite/index.js.map +1 -1
- package/dist/vite/manifest.d.ts +3 -0
- package/dist/vite/manifest.d.ts.map +1 -0
- package/dist/vite/manifest.js +140 -0
- package/dist/vite/manifest.js.map +1 -0
- package/package.json +15 -9
- package/.turbo/turbo-build.log +0 -2
- package/.turbo/turbo-type$colon$emit.log +0 -1
- package/.turbo/turbo-type.log +0 -1
- package/CHANGELOG.md +0 -41
- package/src/components/main-layout.tsx +0 -45
- package/src/components/nav-bar.tsx +0 -41
- package/src/components/nav-tabs.test.tsx +0 -60
- package/src/components/nav-tabs.tsx +0 -47
- package/src/components/not-found-page.tsx +0 -14
- package/src/components/settings-nav.tsx +0 -35
- package/src/components/user-menu.tsx +0 -59
- package/src/components/workspace-crumb.tsx +0 -43
- package/src/components/workspace-switcher.tsx +0 -75
- package/src/compose/compose-routes.test.ts +0 -112
- package/src/compose/compose-routes.ts +0 -60
- package/src/compose/errors.ts +0 -54
- package/src/compose/merge-config.test.ts +0 -26
- package/src/compose/merge-config.ts +0 -27
- package/src/compose/normalize-route-path.ts +0 -6
- package/src/compose/validate-providers.test.ts +0 -31
- package/src/compose/validate-providers.ts +0 -36
- package/src/compose/validate-registries.test.ts +0 -92
- package/src/compose/validate-registries.ts +0 -61
- package/src/config/config-screen.test.tsx +0 -37
- package/src/config/shell-config.ts +0 -10
- package/src/contract.ts +0 -47
- package/src/index.ts +0 -1
- package/src/runtime/active-workspace.ts +0 -13
- package/src/runtime/anchor-paths.ts +0 -18
- package/src/runtime/anchors.tsx +0 -106
- package/src/runtime/assemble-route-tree.ts +0 -42
- package/src/runtime/auth.tsx +0 -213
- package/src/runtime/chrome-context.tsx +0 -22
- package/src/runtime/compose-client-app.tsx +0 -122
- package/src/runtime/define-route.test.ts +0 -15
- package/src/runtime/define-route.ts +0 -33
- package/src/runtime/index.ts +0 -19
- package/src/runtime/last-workspace.ts +0 -22
- package/src/runtime/nav-order.ts +0 -1
- package/src/runtime/provider-stack.tsx +0 -39
- package/src/runtime/providers.test.tsx +0 -51
- package/src/runtime/registries.test.tsx +0 -100
- package/src/runtime/registries.ts +0 -42
- package/src/runtime/router-context.ts +0 -9
- package/src/runtime/routes.test.tsx +0 -48
- package/src/runtime/workspace-setup.tsx +0 -61
- package/src/testing/index.test.tsx +0 -57
- package/src/testing/index.tsx +0 -51
- package/src/vite/evaluate-features.test.ts +0 -89
- package/src/vite/evaluate-features.ts +0 -114
- package/src/vite/generate.test.ts +0 -22
- package/src/vite/generate.ts +0 -99
- package/src/vite/generated-router.test.tsx +0 -58
- package/src/vite/index.ts +0 -1
- package/src/vite/plugin.test.ts +0 -231
- package/src/vite/plugin.ts +0 -101
- package/src/vite/validate.test.ts +0 -166
- package/test/default-route-impl.tsx +0 -3
- package/test/external/FINDINGS.md +0 -41
- package/test/external/README.md +0 -37
- package/test/external/fixture/index.html +0 -12
- package/test/external/fixture/package.json +0 -31
- package/test/external/fixture/src/app.fixture.tsx +0 -104
- package/test/external/fixture/src/features/external-settings.tsx +0 -19
- package/test/external/fixture/src/features/login-override.tsx +0 -9
- package/test/external/fixture/src/features.collision.ts +0 -15
- package/test/external/fixture/src/features.ts +0 -50
- package/test/external/fixture/src/link-typecheck.tsx +0 -10
- package/test/external/fixture/src/main.tsx +0 -86
- package/test/external/fixture/src/provider.ts +0 -45
- package/test/external/fixture/tsconfig.json +0 -15
- package/test/external/fixture/vite.config.ts +0 -21
- package/test/external/fixture/vitest.config.ts +0 -17
- package/test/external/verify.mjs +0 -504
- package/test/fixtures/features.ts +0 -56
- package/test/named-route-impl.tsx +0 -3
- package/test/render.tsx +0 -67
- package/test/search-route-impl.tsx +0 -7
- package/test/setup.ts +0 -3
- package/test/typecheck/shipfox-app.gen.ts +0 -85
- package/test/typecheck/types.tsx +0 -23
- package/tsconfig.build.json +0 -10
- package/tsconfig.build.tsbuildinfo +0 -1
- package/tsconfig.json +0 -1
- package/tsconfig.test.json +0 -12
- package/vitest.config.ts +0 -22
package/src/vite/generate.ts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import type {ComposedRoute} from '#compose/compose-routes.js';
|
|
2
|
-
import type {NavTabEntry, SettingsSectionEntry} from '#contract.js';
|
|
3
|
-
import {routePathForAnchor} from '#runtime/anchor-paths.js';
|
|
4
|
-
|
|
5
|
-
export interface GenerateAppModuleOptions {
|
|
6
|
-
routes: readonly ComposedRoute[];
|
|
7
|
-
navigation: readonly NavTabEntry[];
|
|
8
|
-
settingsSections: readonly SettingsSectionEntry[];
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function literal(value: unknown): string {
|
|
12
|
-
return JSON.stringify(value, undefined, 2);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function indentedLiteral(value: unknown, indentation: number): string {
|
|
16
|
-
return literal(value).replaceAll('\n', `\n${' '.repeat(indentation)}`);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function routeNames(routes: readonly ComposedRoute[], parent: ComposedRoute['parent']): string {
|
|
20
|
-
return routes
|
|
21
|
-
.map((route, index) => ({route, index}))
|
|
22
|
-
.filter(({route}) => route.parent === parent)
|
|
23
|
-
.map(({index}) => `route${index}`)
|
|
24
|
-
.join(', ');
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function generateAppModule({
|
|
28
|
-
routes,
|
|
29
|
-
navigation,
|
|
30
|
-
settingsSections,
|
|
31
|
-
}: GenerateAppModuleOptions): string {
|
|
32
|
-
const generatedRoutes = routes.map((route) => ({
|
|
33
|
-
...route,
|
|
34
|
-
routePath: routePathForAnchor(route.parent, route.path),
|
|
35
|
-
}));
|
|
36
|
-
const imports = routes
|
|
37
|
-
.map((route, index) => `import * as route${index}Module from ${literal(route.impl)};`)
|
|
38
|
-
.join('\n');
|
|
39
|
-
const routeDeclarations = generatedRoutes
|
|
40
|
-
.map(
|
|
41
|
-
(route, index) => `const route${index} = createRoute({
|
|
42
|
-
getParentRoute: () => ${route.parent === 'root' ? 'skeleton.rootRoute' : `skeleton.${route.parent}`},
|
|
43
|
-
path: ${literal(route.routePath)},
|
|
44
|
-
...routeOptions(route${index}Module.default, ${literal(route.impl)}, ${literal(route.path)}),
|
|
45
|
-
});`,
|
|
46
|
-
)
|
|
47
|
-
.join('\n\n');
|
|
48
|
-
const rootRoutes = routeNames(generatedRoutes, 'root');
|
|
49
|
-
const workspaceLayoutRoutes = routeNames(generatedRoutes, 'workspaceLayout');
|
|
50
|
-
const projectLayoutRoutes = routeNames(generatedRoutes, 'projectLayout');
|
|
51
|
-
const workspaceSettingsRoutes = routeNames(generatedRoutes, 'workspaceSettings');
|
|
52
|
-
const rootChildren = [rootRoutes, 'workspaceLayout'].filter(Boolean).join(',\n ');
|
|
53
|
-
|
|
54
|
-
return `// GENERATED by @shipfox/client-shell/vite. Do not edit.
|
|
55
|
-
// biome-ignore-all format: generated code has stable, reviewable output.
|
|
56
|
-
// biome-ignore-all assist/source/organizeImports: generated imports follow route order.
|
|
57
|
-
import {createRoute, createRouter} from '@tanstack/react-router';
|
|
58
|
-
import {buildAnchorSkeleton, isRouteImpl, type RouteImpl, type RouterContext} from '@shipfox/client-shell/runtime';
|
|
59
|
-
${imports}
|
|
60
|
-
|
|
61
|
-
function routeOptions<T extends RouteImpl>(routeImpl: T, impl: string, path: string): T['options'] {
|
|
62
|
-
if (!isRouteImpl(routeImpl)) {
|
|
63
|
-
throw new TypeError(\`Route implementation "\${impl}" for "\${path}" must export default defineRoute(...).\`);
|
|
64
|
-
}
|
|
65
|
-
return routeImpl.options;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const skeleton = buildAnchorSkeleton({
|
|
69
|
-
navigation: ${indentedLiteral(navigation, 2)},
|
|
70
|
-
settingsSections: ${indentedLiteral(settingsSections, 2)},
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
${routeDeclarations}
|
|
74
|
-
|
|
75
|
-
const projectLayout = skeleton.projectLayout.addChildren([${projectLayoutRoutes}]);
|
|
76
|
-
const workspaceSettings = skeleton.workspaceSettings.addChildren([${workspaceSettingsRoutes}]);
|
|
77
|
-
const workspaceLayout = skeleton.workspaceLayout.addChildren([
|
|
78
|
-
${workspaceLayoutRoutes}${workspaceLayoutRoutes ? ',' : ''}
|
|
79
|
-
projectLayout,
|
|
80
|
-
workspaceSettings,
|
|
81
|
-
]);
|
|
82
|
-
|
|
83
|
-
export const routeTree = skeleton.rootRoute.addChildren([
|
|
84
|
-
${rootChildren},
|
|
85
|
-
]);
|
|
86
|
-
|
|
87
|
-
export const router = createRouter({
|
|
88
|
-
routeTree,
|
|
89
|
-
context: {auth: undefined, queryClient: undefined} satisfies RouterContext,
|
|
90
|
-
scrollRestoration: true,
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
declare module '@tanstack/react-router' {
|
|
94
|
-
interface Register {
|
|
95
|
-
router: typeof router;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
`;
|
|
99
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import {QueryClient} from '@tanstack/react-query';
|
|
2
|
-
import {createMemoryHistory, Outlet, RouterProvider} from '@tanstack/react-router';
|
|
3
|
-
import {render, screen} from '@testing-library/react';
|
|
4
|
-
import {createStore} from 'jotai';
|
|
5
|
-
import {authStateAtom} from '#runtime/auth.js';
|
|
6
|
-
import {type AuthStateValue, ChromeProvider, type ChromeSlots} from '#runtime/index.js';
|
|
7
|
-
import {ShellProviderStack} from '#runtime/provider-stack.js';
|
|
8
|
-
import {router} from '#test/typecheck/shipfox-app.gen.js';
|
|
9
|
-
|
|
10
|
-
describe('generated composition router', () => {
|
|
11
|
-
test('renders added and overridden routes', async () => {
|
|
12
|
-
const auth: AuthStateValue = {
|
|
13
|
-
status: 'authenticated',
|
|
14
|
-
workspaces: [{id: 'workspace', name: 'Workspace', membershipId: 'membership'}],
|
|
15
|
-
isLoading: false,
|
|
16
|
-
isAuthenticated: true,
|
|
17
|
-
hasWorkspace: true,
|
|
18
|
-
};
|
|
19
|
-
const chrome: ChromeSlots = {
|
|
20
|
-
ProjectBreadcrumb: () => null,
|
|
21
|
-
ProjectLayoutGuard: Outlet,
|
|
22
|
-
};
|
|
23
|
-
const queryClient = new QueryClient();
|
|
24
|
-
const store = createStore();
|
|
25
|
-
store.set(authStateAtom, auth);
|
|
26
|
-
router.update({
|
|
27
|
-
history: createMemoryHistory({initialEntries: ['/workspaces/workspace/insights']}),
|
|
28
|
-
context: {
|
|
29
|
-
auth,
|
|
30
|
-
queryClient,
|
|
31
|
-
workspaceSetup: async () => ({hideProjectNavigation: false}),
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
render(
|
|
36
|
-
<ChromeProvider chrome={chrome}>
|
|
37
|
-
<ShellProviderStack
|
|
38
|
-
features={[]}
|
|
39
|
-
queryClient={queryClient}
|
|
40
|
-
store={store}
|
|
41
|
-
auth={{effects: false}}
|
|
42
|
-
>
|
|
43
|
-
<RouterProvider router={router} />
|
|
44
|
-
</ShellProviderStack>
|
|
45
|
-
</ChromeProvider>,
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
expect(await screen.findByText('Named route')).toBeVisible();
|
|
49
|
-
|
|
50
|
-
await router.navigate({
|
|
51
|
-
to: '/workspaces/$wid/projects/$pid/overview',
|
|
52
|
-
params: {wid: 'workspace', pid: 'project'},
|
|
53
|
-
search: {tab: 'overview'},
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
expect(await screen.findByText('Search route')).toBeVisible();
|
|
57
|
-
});
|
|
58
|
-
});
|
package/src/vite/index.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {shipfoxClientComposition} from './plugin.js';
|
package/src/vite/plugin.test.ts
DELETED
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import {mkdtemp, readFile, realpath, rm, writeFile} from 'node:fs/promises';
|
|
2
|
-
import {tmpdir} from 'node:os';
|
|
3
|
-
import {join} from 'node:path';
|
|
4
|
-
import {fileURLToPath} from 'node:url';
|
|
5
|
-
import {type Plugin, type ResolvedConfig, build as viteBuild} from 'vite';
|
|
6
|
-
import {shipfoxClientComposition} from './plugin.js';
|
|
7
|
-
|
|
8
|
-
const fixtureFeatures = fileURLToPath(new URL('../../test/fixtures/features.ts', import.meta.url));
|
|
9
|
-
const testDirectory = fileURLToPath(new URL('../../test', import.meta.url));
|
|
10
|
-
|
|
11
|
-
type HookHandler<T> = T extends {handler: infer Handler} ? Handler : T;
|
|
12
|
-
type BuildStartOptions = Parameters<HookHandler<NonNullable<Plugin['buildStart']>>>[0];
|
|
13
|
-
type ConfigResolvedHandler = (config: ResolvedConfig) => void | Promise<void>;
|
|
14
|
-
|
|
15
|
-
function hookHandler<T>(hook: T): HookHandler<T> {
|
|
16
|
-
return (
|
|
17
|
-
typeof hook === 'object' && hook !== null && 'handler' in hook ? hook.handler : hook
|
|
18
|
-
) as HookHandler<T>;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function configResolvedHandler(hook: NonNullable<Plugin['configResolved']>): ConfigResolvedHandler {
|
|
22
|
-
return hookHandler(hook) as ConfigResolvedHandler;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function configure(plugin: Plugin): void {
|
|
26
|
-
if (!plugin.configResolved) {
|
|
27
|
-
throw new Error('Expected the composition plugin to implement configResolved.');
|
|
28
|
-
}
|
|
29
|
-
configResolvedHandler(plugin.configResolved)({
|
|
30
|
-
root: process.cwd(),
|
|
31
|
-
} as ResolvedConfig);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function resolveRouteImplementation(source: string): string | undefined {
|
|
35
|
-
const files: Record<string, string> = {
|
|
36
|
-
'#test/default-route-impl.js': join(testDirectory, 'default-route-impl.tsx'),
|
|
37
|
-
'#test/named-route-impl.js': join(testDirectory, 'named-route-impl.tsx'),
|
|
38
|
-
'#test/search-route-impl.js': join(testDirectory, 'search-route-impl.tsx'),
|
|
39
|
-
};
|
|
40
|
-
return files[source];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function pluginContext(resolveImplementation = resolveRouteImplementation) {
|
|
44
|
-
const watchedFiles: string[] = [];
|
|
45
|
-
return {
|
|
46
|
-
watchedFiles,
|
|
47
|
-
addWatchFile(file: string) {
|
|
48
|
-
watchedFiles.push(file);
|
|
49
|
-
},
|
|
50
|
-
resolve(source: string) {
|
|
51
|
-
const id = resolveImplementation(source);
|
|
52
|
-
return Promise.resolve(id ? {id} : null);
|
|
53
|
-
},
|
|
54
|
-
environment: {
|
|
55
|
-
pluginContainer: {
|
|
56
|
-
resolveId(source: string) {
|
|
57
|
-
const id = resolveImplementation(source);
|
|
58
|
-
return Promise.resolve(id ? {id} : null);
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function build(plugin: Plugin, context: ReturnType<typeof pluginContext>): Promise<void> {
|
|
66
|
-
if (!plugin.buildStart) {
|
|
67
|
-
throw new Error('Expected the composition plugin to implement buildStart.');
|
|
68
|
-
}
|
|
69
|
-
await (
|
|
70
|
-
hookHandler(plugin.buildStart) as unknown as (
|
|
71
|
-
this: typeof context,
|
|
72
|
-
options: BuildStartOptions,
|
|
73
|
-
) => Promise<void>
|
|
74
|
-
).call(context, {} as BuildStartOptions);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
describe('shipfoxClientComposition', () => {
|
|
78
|
-
let directory: string;
|
|
79
|
-
|
|
80
|
-
beforeEach(async () => {
|
|
81
|
-
directory = await mkdtemp(join(tmpdir(), 'shipfox-client-shell-vite-'));
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
afterEach(async () => {
|
|
85
|
-
await rm(directory, {recursive: true, force: true});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('generates a static route tree and watches the manifest module graph', async () => {
|
|
89
|
-
const out = join(directory, 'shipfox-app.gen.ts');
|
|
90
|
-
const plugin = shipfoxClientComposition({features: fixtureFeatures, out});
|
|
91
|
-
const context = pluginContext();
|
|
92
|
-
configure(plugin);
|
|
93
|
-
|
|
94
|
-
await build(plugin, context);
|
|
95
|
-
|
|
96
|
-
await expect(readFile(out, 'utf8')).resolves.toContain(
|
|
97
|
-
'import * as route0Module from "#test/search-route-impl.js";',
|
|
98
|
-
);
|
|
99
|
-
expect(context.watchedFiles).toContain(fixtureFeatures);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test('fails the Vite build with the shared collision message', async () => {
|
|
103
|
-
const features = join(directory, 'features.ts');
|
|
104
|
-
await writeFile(
|
|
105
|
-
features,
|
|
106
|
-
`export const features = [
|
|
107
|
-
{id: 'shipfox.projects', routes: [{path: '/projects', parent: 'root', impl: '#test/default-route-impl.js'}]},
|
|
108
|
-
{id: 'acme.projects', routes: [{path: '/projects', parent: 'root', impl: '#test/default-route-impl.js'}]},
|
|
109
|
-
];`,
|
|
110
|
-
);
|
|
111
|
-
const plugin = shipfoxClientComposition({features, out: join(directory, 'shipfox-app.gen.ts')});
|
|
112
|
-
const context = pluginContext();
|
|
113
|
-
configure(plugin);
|
|
114
|
-
|
|
115
|
-
await expect(build(plugin, context)).rejects.toThrow(
|
|
116
|
-
'Route "/projects" is contributed by both features "shipfox.projects" and "acme.projects". Set override: true to replace it explicitly.',
|
|
117
|
-
);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test('fails the Vite build when a route implementation cannot resolve', async () => {
|
|
121
|
-
const features = join(directory, 'features.ts');
|
|
122
|
-
await writeFile(
|
|
123
|
-
features,
|
|
124
|
-
`export const features = [{id: 'acme.projects', routes: [{path: '/projects', parent: 'root', impl: '#test/missing-route-impl.js'}]}];`,
|
|
125
|
-
);
|
|
126
|
-
const plugin = shipfoxClientComposition({features, out: join(directory, 'shipfox-app.gen.ts')});
|
|
127
|
-
const context = pluginContext();
|
|
128
|
-
configure(plugin);
|
|
129
|
-
|
|
130
|
-
await expect(build(plugin, context)).rejects.toThrow(
|
|
131
|
-
'Could not resolve route implementation "#test/missing-route-impl.js" for "/projects".',
|
|
132
|
-
);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test('generates when Vite resolves a route implementation with a version query', async () => {
|
|
136
|
-
const out = join(directory, 'shipfox-app.gen.ts');
|
|
137
|
-
const plugin = shipfoxClientComposition({features: fixtureFeatures, out});
|
|
138
|
-
const context = pluginContext((source) => {
|
|
139
|
-
if (source === '#test/search-route-impl.js') {
|
|
140
|
-
return `${join(testDirectory, 'search-route-impl.tsx')}?v=abc123`;
|
|
141
|
-
}
|
|
142
|
-
return resolveRouteImplementation(source);
|
|
143
|
-
});
|
|
144
|
-
configure(plugin);
|
|
145
|
-
|
|
146
|
-
await build(plugin, context);
|
|
147
|
-
|
|
148
|
-
await expect(readFile(out, 'utf8')).resolves.toContain(
|
|
149
|
-
'import * as route0Module from "#test/search-route-impl.js";',
|
|
150
|
-
);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test('bundles a named-only route module so the runtime export guard can report it', async () => {
|
|
154
|
-
const features = join(directory, 'features.ts');
|
|
155
|
-
const implementation = join(directory, 'not-route-impl.ts');
|
|
156
|
-
const output = join(directory, 'shipfox-app.gen.ts');
|
|
157
|
-
const entry = join(directory, 'main.ts');
|
|
158
|
-
await Promise.all([
|
|
159
|
-
writeFile(
|
|
160
|
-
features,
|
|
161
|
-
`export const features = [{id: 'acme.projects', routes: [{path: '/projects', parent: 'root', impl: './not-route-impl.ts'}]}];`,
|
|
162
|
-
),
|
|
163
|
-
writeFile(implementation, 'export const Route = () => null;'),
|
|
164
|
-
writeFile(entry, "import './shipfox-app.gen.ts';"),
|
|
165
|
-
]);
|
|
166
|
-
|
|
167
|
-
await expect(
|
|
168
|
-
viteBuild({
|
|
169
|
-
root: process.cwd(),
|
|
170
|
-
logLevel: 'silent',
|
|
171
|
-
plugins: [shipfoxClientComposition({features, out: output})],
|
|
172
|
-
build: {
|
|
173
|
-
write: false,
|
|
174
|
-
rolldownOptions: {
|
|
175
|
-
input: entry,
|
|
176
|
-
external: ['@shipfox/client-shell/runtime', '@tanstack/react-router'],
|
|
177
|
-
},
|
|
178
|
-
},
|
|
179
|
-
}),
|
|
180
|
-
).resolves.toBeDefined();
|
|
181
|
-
await expect(readFile(output, 'utf8')).resolves.toContain(
|
|
182
|
-
'routeOptions(route0Module.default, "./not-route-impl.ts", "/projects")',
|
|
183
|
-
);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test('regenerates when a watched manifest changes', async () => {
|
|
187
|
-
const features = join(directory, 'features.ts');
|
|
188
|
-
const contribution = join(directory, 'contribution.ts');
|
|
189
|
-
const out = join(directory, 'shipfox-app.gen.ts');
|
|
190
|
-
await writeFile(
|
|
191
|
-
features,
|
|
192
|
-
`import {feature} from './contribution.ts';
|
|
193
|
-
export const features = [feature];`,
|
|
194
|
-
);
|
|
195
|
-
await writeFile(
|
|
196
|
-
contribution,
|
|
197
|
-
`export const feature = {id: 'acme.insights', routes: [{path: '/insights', parent: 'root', impl: '#test/default-route-impl.js'}]};`,
|
|
198
|
-
);
|
|
199
|
-
const plugin = shipfoxClientComposition({features, out});
|
|
200
|
-
const context = pluginContext();
|
|
201
|
-
configure(plugin);
|
|
202
|
-
|
|
203
|
-
await build(plugin, context);
|
|
204
|
-
expect(context.watchedFiles).toContain(await realpath(contribution));
|
|
205
|
-
await writeFile(
|
|
206
|
-
contribution,
|
|
207
|
-
`export const feature = {id: 'acme.reports', routes: [{path: '/reports', parent: 'root', impl: '#test/default-route-impl.js'}]};`,
|
|
208
|
-
);
|
|
209
|
-
const hotUpdateContext = {environment: context.environment};
|
|
210
|
-
await (
|
|
211
|
-
plugin.hotUpdate as unknown as (
|
|
212
|
-
this: typeof hotUpdateContext,
|
|
213
|
-
options: {
|
|
214
|
-
file: string;
|
|
215
|
-
server: {watcher: {add(file: string): void}};
|
|
216
|
-
},
|
|
217
|
-
) => Promise<void>
|
|
218
|
-
).call(hotUpdateContext, {
|
|
219
|
-
file: contribution,
|
|
220
|
-
server: {
|
|
221
|
-
watcher: {
|
|
222
|
-
add(file: string) {
|
|
223
|
-
context.watchedFiles.push(file);
|
|
224
|
-
},
|
|
225
|
-
},
|
|
226
|
-
},
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
await expect(readFile(out, 'utf8')).resolves.toContain('path: "/reports"');
|
|
230
|
-
});
|
|
231
|
-
});
|
package/src/vite/plugin.ts
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import {mkdir, readFile, realpath, writeFile} from 'node:fs/promises';
|
|
2
|
-
import {dirname, resolve} from 'node:path';
|
|
3
|
-
import type {Plugin, ResolvedConfig} from 'vite';
|
|
4
|
-
import {composeRoutes} from '#compose/compose-routes.js';
|
|
5
|
-
import {mergeConfigShapes} from '#compose/merge-config.js';
|
|
6
|
-
import {validateProviderIds} from '#compose/validate-providers.js';
|
|
7
|
-
import {validateNavigation, validateSettingsSections} from '#compose/validate-registries.js';
|
|
8
|
-
import {navigationEntries, settingsEntries} from '#runtime/registries.js';
|
|
9
|
-
import {evaluateFeatures} from './evaluate-features.js';
|
|
10
|
-
import {generateAppModule} from './generate.js';
|
|
11
|
-
|
|
12
|
-
export interface ShipfoxClientCompositionOptions {
|
|
13
|
-
features: string;
|
|
14
|
-
out?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
type RouteResolver = (source: string, importer?: string) => Promise<{id: string} | null>;
|
|
18
|
-
|
|
19
|
-
export function shipfoxClientComposition({
|
|
20
|
-
features,
|
|
21
|
-
out = './src/shipfox-app.gen.ts',
|
|
22
|
-
}: ShipfoxClientCompositionOptions): Plugin {
|
|
23
|
-
let config: ResolvedConfig | undefined;
|
|
24
|
-
let watchedFiles = new Set<string>();
|
|
25
|
-
|
|
26
|
-
const outputPath = () => resolve(config?.root ?? process.cwd(), out);
|
|
27
|
-
const featuresPath = () => resolve(config?.root ?? process.cwd(), features);
|
|
28
|
-
|
|
29
|
-
async function assertRoutesResolve(
|
|
30
|
-
resolveRoute: RouteResolver,
|
|
31
|
-
routes: ReturnType<typeof composeRoutes>,
|
|
32
|
-
): Promise<void> {
|
|
33
|
-
for (const route of routes) {
|
|
34
|
-
const resolvedRoute = await resolveRoute(route.impl, outputPath());
|
|
35
|
-
if (!resolvedRoute) {
|
|
36
|
-
throw new Error(
|
|
37
|
-
`Could not resolve route implementation "${route.impl}" for "${route.path}".`,
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async function generate({
|
|
44
|
-
addWatchFile,
|
|
45
|
-
resolveRoute,
|
|
46
|
-
}: {
|
|
47
|
-
addWatchFile(file: string): void;
|
|
48
|
-
resolveRoute: RouteResolver;
|
|
49
|
-
}): Promise<void> {
|
|
50
|
-
const evaluated = await evaluateFeatures(featuresPath());
|
|
51
|
-
const routes = composeRoutes(evaluated.features);
|
|
52
|
-
validateProviderIds(evaluated.features);
|
|
53
|
-
validateNavigation(
|
|
54
|
-
evaluated.features,
|
|
55
|
-
routes.map((route) => route.path),
|
|
56
|
-
);
|
|
57
|
-
validateSettingsSections(
|
|
58
|
-
evaluated.features,
|
|
59
|
-
routes.map((route) => route.path),
|
|
60
|
-
);
|
|
61
|
-
mergeConfigShapes(evaluated.features);
|
|
62
|
-
await assertRoutesResolve(resolveRoute, routes);
|
|
63
|
-
|
|
64
|
-
watchedFiles = new Set(evaluated.loadedFiles);
|
|
65
|
-
for (const file of watchedFiles) addWatchFile(file);
|
|
66
|
-
|
|
67
|
-
const output = generateAppModule({
|
|
68
|
-
routes,
|
|
69
|
-
navigation: navigationEntries(evaluated.features),
|
|
70
|
-
settingsSections: settingsEntries(evaluated.features),
|
|
71
|
-
});
|
|
72
|
-
const path = outputPath();
|
|
73
|
-
const existing = await readFile(path, 'utf8').catch(() => undefined);
|
|
74
|
-
if (existing === output) return;
|
|
75
|
-
await mkdir(dirname(path), {recursive: true});
|
|
76
|
-
await writeFile(path, output);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return {
|
|
80
|
-
name: 'shipfox-client-composition',
|
|
81
|
-
configResolved(resolvedConfig) {
|
|
82
|
-
config = resolvedConfig;
|
|
83
|
-
},
|
|
84
|
-
async buildStart() {
|
|
85
|
-
await generate({
|
|
86
|
-
addWatchFile: this.addWatchFile.bind(this),
|
|
87
|
-
resolveRoute: this.resolve.bind(this),
|
|
88
|
-
});
|
|
89
|
-
},
|
|
90
|
-
async hotUpdate({file, server}) {
|
|
91
|
-
const resolvedFile = await realpath(file).catch(() => resolve(file));
|
|
92
|
-
if (!watchedFiles.has(resolvedFile)) return;
|
|
93
|
-
await generate({
|
|
94
|
-
addWatchFile: server.watcher.add.bind(server.watcher),
|
|
95
|
-
resolveRoute: this.environment.pluginContainer.resolveId.bind(
|
|
96
|
-
this.environment.pluginContainer,
|
|
97
|
-
),
|
|
98
|
-
});
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
}
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
import {z} from 'zod';
|
|
2
|
-
import {composeRoutes} from '#compose/compose-routes.js';
|
|
3
|
-
import {mergeConfigShapes} from '#compose/merge-config.js';
|
|
4
|
-
import {validateProviderIds} from '#compose/validate-providers.js';
|
|
5
|
-
import {validateNavigation, validateSettingsSections} from '#compose/validate-registries.js';
|
|
6
|
-
import {defineClientFeature} from '#contract.js';
|
|
7
|
-
|
|
8
|
-
const route = {path: '/projects', parent: 'root' as const, impl: '#test/default-route-impl.js'};
|
|
9
|
-
|
|
10
|
-
describe('composition validation', () => {
|
|
11
|
-
test('accepts non-conflicting feature contributions', () => {
|
|
12
|
-
const features = [
|
|
13
|
-
defineClientFeature({
|
|
14
|
-
id: 'acme.projects',
|
|
15
|
-
routes: [
|
|
16
|
-
route,
|
|
17
|
-
{
|
|
18
|
-
path: '/workspaces/$wid/settings/members',
|
|
19
|
-
parent: 'workspaceSettings',
|
|
20
|
-
impl: '#test/default-route-impl.js',
|
|
21
|
-
},
|
|
22
|
-
],
|
|
23
|
-
providers: [{id: 'acme-theme', Component: () => null}],
|
|
24
|
-
navigation: [{id: 'projects', scope: 'workspace', label: 'Projects', to: '/projects'}],
|
|
25
|
-
settingsSections: [
|
|
26
|
-
{id: 'members', pathSegment: 'members', label: 'Members', icon: 'users'},
|
|
27
|
-
],
|
|
28
|
-
configShape: {projectId: z.string()},
|
|
29
|
-
}),
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
const routes = composeRoutes(features);
|
|
33
|
-
|
|
34
|
-
expect(() => validateProviderIds(features)).not.toThrow();
|
|
35
|
-
expect(() =>
|
|
36
|
-
validateNavigation(
|
|
37
|
-
features,
|
|
38
|
-
routes.map(({path}) => path),
|
|
39
|
-
),
|
|
40
|
-
).not.toThrow();
|
|
41
|
-
expect(() =>
|
|
42
|
-
validateSettingsSections(
|
|
43
|
-
features,
|
|
44
|
-
routes.map(({path}) => path),
|
|
45
|
-
),
|
|
46
|
-
).not.toThrow();
|
|
47
|
-
expect(() => mergeConfigShapes(features)).not.toThrow();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('reports route collisions with both feature ids', () => {
|
|
51
|
-
const features = [
|
|
52
|
-
defineClientFeature({id: 'shipfox.projects', routes: [route]}),
|
|
53
|
-
defineClientFeature({id: 'acme.projects', routes: [route]}),
|
|
54
|
-
];
|
|
55
|
-
|
|
56
|
-
expect(() => composeRoutes(features)).toThrow(
|
|
57
|
-
'Route "/projects" is contributed by both features "shipfox.projects" and "acme.projects". Set override: true to replace it explicitly.',
|
|
58
|
-
);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test('reports dangling and competing overrides with their feature ids', () => {
|
|
62
|
-
const dangling = [
|
|
63
|
-
defineClientFeature({id: 'acme.projects', routes: [{...route, override: true}]}),
|
|
64
|
-
];
|
|
65
|
-
const competing = [
|
|
66
|
-
defineClientFeature({id: 'shipfox.projects', routes: [route]}),
|
|
67
|
-
defineClientFeature({id: 'acme.one', routes: [{...route, override: true}]}),
|
|
68
|
-
defineClientFeature({id: 'acme.two', routes: [{...route, override: true}]}),
|
|
69
|
-
];
|
|
70
|
-
|
|
71
|
-
expect(() => composeRoutes(dangling)).toThrow(
|
|
72
|
-
'Route override for "/projects" from feature "acme.projects" has no route to replace.',
|
|
73
|
-
);
|
|
74
|
-
expect(() => composeRoutes(competing)).toThrow(
|
|
75
|
-
'Route "/projects" has competing overrides from features "acme.one" and "acme.two".',
|
|
76
|
-
);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test('reports reserved and duplicate provider ids', () => {
|
|
80
|
-
const reserved = [
|
|
81
|
-
defineClientFeature({id: 'acme.theme', providers: [{id: 'theme', Component: () => null}]}),
|
|
82
|
-
];
|
|
83
|
-
const duplicate = [
|
|
84
|
-
defineClientFeature({id: 'acme.one', providers: [{id: 'flags', Component: () => null}]}),
|
|
85
|
-
defineClientFeature({id: 'acme.two', providers: [{id: 'flags', Component: () => null}]}),
|
|
86
|
-
];
|
|
87
|
-
|
|
88
|
-
expect(() => validateProviderIds(reserved)).toThrow(
|
|
89
|
-
'Provider id "theme" in feature "acme.theme" is reserved by the shell.',
|
|
90
|
-
);
|
|
91
|
-
expect(() => validateProviderIds(duplicate)).toThrow(
|
|
92
|
-
'Provider id "flags" is contributed by both features "acme.one" and "acme.two".',
|
|
93
|
-
);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test('reports duplicate and missing navigation entries', () => {
|
|
97
|
-
const duplicate = [
|
|
98
|
-
defineClientFeature({
|
|
99
|
-
id: 'acme.one',
|
|
100
|
-
navigation: [{id: 'projects', scope: 'workspace', label: 'Projects', to: '/projects'}],
|
|
101
|
-
}),
|
|
102
|
-
defineClientFeature({
|
|
103
|
-
id: 'acme.two',
|
|
104
|
-
navigation: [{id: 'projects', scope: 'workspace', label: 'Projects', to: '/projects'}],
|
|
105
|
-
}),
|
|
106
|
-
];
|
|
107
|
-
const missing = [
|
|
108
|
-
defineClientFeature({
|
|
109
|
-
id: 'acme.insights',
|
|
110
|
-
navigation: [{id: 'insights', scope: 'workspace', label: 'Insights', to: '/insights'}],
|
|
111
|
-
}),
|
|
112
|
-
];
|
|
113
|
-
|
|
114
|
-
expect(() => validateNavigation(duplicate, ['/projects'])).toThrow(
|
|
115
|
-
'Navigation entry "projects" is contributed by both features "acme.one" and "acme.two".',
|
|
116
|
-
);
|
|
117
|
-
expect(() => validateNavigation(missing, [])).toThrow(
|
|
118
|
-
'Navigation entry "insights" in feature "acme.insights" targets missing route "/insights".',
|
|
119
|
-
);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test('reports duplicate and missing settings sections', () => {
|
|
123
|
-
const duplicate = [
|
|
124
|
-
defineClientFeature({
|
|
125
|
-
id: 'acme.one',
|
|
126
|
-
settingsSections: [
|
|
127
|
-
{id: 'members', pathSegment: 'members', label: 'Members', icon: 'users'},
|
|
128
|
-
],
|
|
129
|
-
}),
|
|
130
|
-
defineClientFeature({
|
|
131
|
-
id: 'acme.two',
|
|
132
|
-
settingsSections: [
|
|
133
|
-
{id: 'members', pathSegment: 'members', label: 'Members', icon: 'users'},
|
|
134
|
-
],
|
|
135
|
-
}),
|
|
136
|
-
];
|
|
137
|
-
const missing = [
|
|
138
|
-
defineClientFeature({
|
|
139
|
-
id: 'acme.insights',
|
|
140
|
-
settingsSections: [
|
|
141
|
-
{id: 'insights', pathSegment: 'insights', label: 'Insights', icon: 'chart-line'},
|
|
142
|
-
],
|
|
143
|
-
}),
|
|
144
|
-
];
|
|
145
|
-
|
|
146
|
-
expect(() =>
|
|
147
|
-
validateSettingsSections(duplicate, ['/workspaces/$wid/settings/members']),
|
|
148
|
-
).toThrow(
|
|
149
|
-
'Settings section "members" is contributed by both features "acme.one" and "acme.two".',
|
|
150
|
-
);
|
|
151
|
-
expect(() => validateSettingsSections(missing, [])).toThrow(
|
|
152
|
-
'Settings section "insights" in feature "acme.insights" requires route "/workspaces/$wid/settings/insights".',
|
|
153
|
-
);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test('reports duplicate config keys', () => {
|
|
157
|
-
const features = [
|
|
158
|
-
defineClientFeature({id: 'acme.one', configShape: {token: z.string()}}),
|
|
159
|
-
defineClientFeature({id: 'acme.two', configShape: {token: z.string()}}),
|
|
160
|
-
];
|
|
161
|
-
|
|
162
|
-
expect(() => mergeConfigShapes(features)).toThrow(
|
|
163
|
-
'Config key "token" is contributed by both features "acme.one" and "acme.two". Reuse the same schema instance to intentionally share it.',
|
|
164
|
-
);
|
|
165
|
-
});
|
|
166
|
-
});
|