@shipfox/client-shell 0.2.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/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/dist/components/main-layout.d.ts +11 -0
- package/dist/components/main-layout.d.ts.map +1 -0
- package/dist/components/main-layout.js +39 -0
- package/dist/components/main-layout.js.map +1 -0
- package/dist/components/nav-bar.d.ts +4 -0
- package/dist/components/nav-bar.d.ts.map +1 -0
- package/dist/components/nav-bar.js +62 -0
- package/dist/components/nav-bar.js.map +1 -0
- package/dist/components/nav-tabs.d.ts +6 -0
- package/dist/components/nav-tabs.d.ts.map +1 -0
- package/dist/components/nav-tabs.js +38 -0
- package/dist/components/nav-tabs.js.map +1 -0
- package/dist/components/not-found-page.d.ts +2 -0
- package/dist/components/not-found-page.d.ts.map +1 -0
- package/dist/components/not-found-page.js +25 -0
- package/dist/components/not-found-page.js.map +1 -0
- package/dist/components/settings-nav.d.ts +5 -0
- package/dist/components/settings-nav.d.ts.map +1 -0
- package/dist/components/settings-nav.js +45 -0
- package/dist/components/settings-nav.js.map +1 -0
- package/dist/components/user-menu.d.ts +2 -0
- package/dist/components/user-menu.d.ts.map +1 -0
- package/dist/components/user-menu.js +76 -0
- package/dist/components/user-menu.js.map +1 -0
- package/dist/components/workspace-crumb.d.ts +7 -0
- package/dist/components/workspace-crumb.d.ts.map +1 -0
- package/dist/components/workspace-crumb.js +54 -0
- package/dist/components/workspace-crumb.js.map +1 -0
- package/dist/components/workspace-switcher.d.ts +5 -0
- package/dist/components/workspace-switcher.d.ts.map +1 -0
- package/dist/components/workspace-switcher.js +90 -0
- package/dist/components/workspace-switcher.js.map +1 -0
- package/dist/compose/compose-routes.d.ts +6 -0
- package/dist/compose/compose-routes.d.ts.map +1 -0
- package/dist/compose/compose-routes.js +53 -0
- package/dist/compose/compose-routes.js.map +1 -0
- package/dist/compose/errors.d.ts +26 -0
- package/dist/compose/errors.d.ts.map +1 -0
- package/dist/compose/errors.js +39 -0
- package/dist/compose/errors.js.map +1 -0
- package/dist/compose/merge-config.d.ts +4 -0
- package/dist/compose/merge-config.d.ts.map +1 -0
- package/dist/compose/merge-config.js +28 -0
- package/dist/compose/merge-config.js.map +1 -0
- package/dist/compose/normalize-route-path.d.ts +2 -0
- package/dist/compose/normalize-route-path.d.ts.map +1 -0
- package/dist/compose/normalize-route-path.js +7 -0
- package/dist/compose/normalize-route-path.js.map +1 -0
- package/dist/compose/validate-providers.d.ts +3 -0
- package/dist/compose/validate-providers.d.ts.map +1 -0
- package/dist/compose/validate-providers.js +32 -0
- package/dist/compose/validate-providers.js.map +1 -0
- package/dist/compose/validate-registries.d.ts +4 -0
- package/dist/compose/validate-registries.d.ts.map +1 -0
- package/dist/compose/validate-registries.js +50 -0
- package/dist/compose/validate-registries.js.map +1 -0
- package/dist/config/shell-config.d.ts +10 -0
- package/dist/config/shell-config.d.ts.map +1 -0
- package/dist/config/shell-config.js +12 -0
- package/dist/config/shell-config.js.map +1 -0
- package/dist/contract.d.ts +39 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +5 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/active-workspace.d.ts +4 -0
- package/dist/runtime/active-workspace.d.ts.map +1 -0
- package/dist/runtime/active-workspace.js +15 -0
- package/dist/runtime/active-workspace.js.map +1 -0
- package/dist/runtime/anchor-paths.d.ts +9 -0
- package/dist/runtime/anchor-paths.d.ts.map +1 -0
- package/dist/runtime/anchor-paths.js +18 -0
- package/dist/runtime/anchor-paths.js.map +1 -0
- package/dist/runtime/anchors.d.ts +19 -0
- package/dist/runtime/anchors.d.ts.map +1 -0
- package/dist/runtime/anchors.js +125 -0
- package/dist/runtime/anchors.js.map +1 -0
- package/dist/runtime/assemble-route-tree.d.ts +10 -0
- package/dist/runtime/assemble-route-tree.d.ts.map +1 -0
- package/dist/runtime/assemble-route-tree.js +30 -0
- package/dist/runtime/assemble-route-tree.js.map +1 -0
- package/dist/runtime/auth.d.ts +50 -0
- package/dist/runtime/auth.d.ts.map +1 -0
- package/dist/runtime/auth.js +194 -0
- package/dist/runtime/auth.js.map +1 -0
- package/dist/runtime/chrome-context.d.ts +10 -0
- package/dist/runtime/chrome-context.d.ts.map +1 -0
- package/dist/runtime/chrome-context.js +16 -0
- package/dist/runtime/chrome-context.js.map +1 -0
- package/dist/runtime/compose-client-app.d.ts +13 -0
- package/dist/runtime/compose-client-app.d.ts.map +1 -0
- package/dist/runtime/compose-client-app.js +93 -0
- package/dist/runtime/compose-client-app.js.map +1 -0
- package/dist/runtime/define-route.d.ts +19 -0
- package/dist/runtime/define-route.d.ts.map +1 -0
- package/dist/runtime/define-route.js +12 -0
- package/dist/runtime/define-route.js.map +1 -0
- package/dist/runtime/index.d.ts +20 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +21 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/last-workspace.d.ts +4 -0
- package/dist/runtime/last-workspace.d.ts.map +1 -0
- package/dist/runtime/last-workspace.js +20 -0
- package/dist/runtime/last-workspace.js.map +1 -0
- package/dist/runtime/nav-order.d.ts +7 -0
- package/dist/runtime/nav-order.d.ts.map +1 -0
- package/dist/runtime/nav-order.js +8 -0
- package/dist/runtime/nav-order.js.map +1 -0
- package/dist/runtime/provider-stack.d.ts +14 -0
- package/dist/runtime/provider-stack.d.ts.map +1 -0
- package/dist/runtime/provider-stack.js +28 -0
- package/dist/runtime/provider-stack.js.map +1 -0
- package/dist/runtime/registries.d.ts +4 -0
- package/dist/runtime/registries.d.ts.map +1 -0
- package/dist/runtime/registries.js +23 -0
- package/dist/runtime/registries.js.map +1 -0
- package/dist/runtime/router-context.d.ts +9 -0
- package/dist/runtime/router-context.d.ts.map +1 -0
- package/dist/runtime/router-context.js +3 -0
- package/dist/runtime/router-context.js.map +1 -0
- package/dist/runtime/workspace-setup.d.ts +18 -0
- package/dist/runtime/workspace-setup.d.ts.map +1 -0
- package/dist/runtime/workspace-setup.js +64 -0
- package/dist/runtime/workspace-setup.js.map +1 -0
- package/dist/testing/index.d.ts +17 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +35 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/dist/vite/evaluate-features.d.ts +8 -0
- package/dist/vite/evaluate-features.d.ts.map +1 -0
- package/dist/vite/evaluate-features.js +80 -0
- package/dist/vite/evaluate-features.js.map +1 -0
- package/dist/vite/generate.d.ts +9 -0
- package/dist/vite/generate.d.ts.map +1 -0
- package/dist/vite/generate.js +80 -0
- package/dist/vite/generate.js.map +1 -0
- package/dist/vite/index.d.ts +2 -0
- package/dist/vite/index.d.ts.map +1 -0
- package/dist/vite/index.js +3 -0
- package/dist/vite/index.js.map +1 -0
- package/dist/vite/plugin.d.ts +7 -0
- package/dist/vite/plugin.d.ts.map +1 -0
- package/dist/vite/plugin.js +77 -0
- package/dist/vite/plugin.js.map +1 -0
- package/package.json +113 -0
- package/src/components/main-layout.tsx +45 -0
- package/src/components/nav-bar.tsx +41 -0
- package/src/components/nav-tabs.test.tsx +60 -0
- package/src/components/nav-tabs.tsx +47 -0
- package/src/components/not-found-page.tsx +14 -0
- package/src/components/settings-nav.tsx +35 -0
- package/src/components/user-menu.tsx +59 -0
- package/src/components/workspace-crumb.tsx +43 -0
- package/src/components/workspace-switcher.tsx +75 -0
- package/src/compose/compose-routes.test.ts +112 -0
- package/src/compose/compose-routes.ts +60 -0
- package/src/compose/errors.ts +54 -0
- package/src/compose/merge-config.test.ts +26 -0
- package/src/compose/merge-config.ts +27 -0
- package/src/compose/normalize-route-path.ts +6 -0
- package/src/compose/validate-providers.test.ts +31 -0
- package/src/compose/validate-providers.ts +36 -0
- package/src/compose/validate-registries.test.ts +92 -0
- package/src/compose/validate-registries.ts +61 -0
- package/src/config/config-screen.test.tsx +37 -0
- package/src/config/shell-config.ts +10 -0
- package/src/contract.ts +47 -0
- package/src/index.ts +1 -0
- package/src/runtime/active-workspace.ts +13 -0
- package/src/runtime/anchor-paths.ts +18 -0
- package/src/runtime/anchors.tsx +106 -0
- package/src/runtime/assemble-route-tree.ts +42 -0
- package/src/runtime/auth.tsx +213 -0
- package/src/runtime/chrome-context.tsx +22 -0
- package/src/runtime/compose-client-app.tsx +122 -0
- package/src/runtime/define-route.test.ts +15 -0
- package/src/runtime/define-route.ts +33 -0
- package/src/runtime/index.ts +19 -0
- package/src/runtime/last-workspace.ts +22 -0
- package/src/runtime/nav-order.ts +1 -0
- package/src/runtime/provider-stack.tsx +39 -0
- package/src/runtime/providers.test.tsx +51 -0
- package/src/runtime/registries.test.tsx +100 -0
- package/src/runtime/registries.ts +42 -0
- package/src/runtime/router-context.ts +9 -0
- package/src/runtime/routes.test.tsx +48 -0
- package/src/runtime/workspace-setup.tsx +61 -0
- package/src/testing/index.test.tsx +57 -0
- package/src/testing/index.tsx +51 -0
- package/src/vite/evaluate-features.test.ts +34 -0
- package/src/vite/evaluate-features.ts +93 -0
- package/src/vite/generate.test.ts +22 -0
- package/src/vite/generate.ts +99 -0
- package/src/vite/generated-router.test.tsx +58 -0
- package/src/vite/index.ts +1 -0
- package/src/vite/plugin.test.ts +181 -0
- package/src/vite/plugin.ts +114 -0
- package/src/vite/validate.test.ts +166 -0
- package/test/default-route-impl.tsx +3 -0
- package/test/external/FINDINGS.md +53 -0
- package/test/external/README.md +39 -0
- package/test/external/fixture/index.html +12 -0
- package/test/external/fixture/package.json +32 -0
- package/test/external/fixture/src/app.fixture.tsx +72 -0
- package/test/external/fixture/src/features/override-impl.tsx +12 -0
- package/test/external/fixture/src/features/override.tsx +20 -0
- package/test/external/fixture/src/features.collision.ts +15 -0
- package/test/external/fixture/src/features.ts +4 -0
- package/test/external/fixture/src/link-typecheck.tsx +10 -0
- package/test/external/fixture/src/main.tsx +4 -0
- package/test/external/fixture/tsconfig.json +15 -0
- package/test/external/fixture/vite.config.ts +10 -0
- package/test/external/fixture/vitest.config.ts +15 -0
- package/test/external/toy-feature/CHANGELOG.md +15 -0
- package/test/external/toy-feature/node_modules/.bin/shipfox-biome-check +43 -0
- package/test/external/toy-feature/node_modules/.bin/shipfox-biome-format +43 -0
- package/test/external/toy-feature/node_modules/.bin/shipfox-biome-lint +43 -0
- package/test/external/toy-feature/node_modules/.bin/shipfox-swc +43 -0
- package/test/external/toy-feature/node_modules/.bin/shipfox-tsc-check +43 -0
- package/test/external/toy-feature/node_modules/.bin/shipfox-tsc-emit +43 -0
- package/test/external/toy-feature/package.json +57 -0
- package/test/external/toy-feature/src/config.ts +5 -0
- package/test/external/toy-feature/src/index.ts +64 -0
- package/test/external/toy-feature/src/provider.tsx +33 -0
- package/test/external/toy-feature/src/routes/insights.tsx +7 -0
- package/test/external/toy-feature/src/routes/settings.tsx +7 -0
- package/test/external/toy-feature/tsconfig.build.json +5 -0
- package/test/external/toy-feature/tsconfig.json +1 -0
- package/test/external/verify.mjs +318 -0
- package/test/fixtures/features.ts +56 -0
- package/test/named-route-impl.tsx +3 -0
- package/test/not-route-impl.ts +1 -0
- package/test/render.tsx +67 -0
- package/test/search-route-impl.tsx +7 -0
- package/test/setup.ts +3 -0
- package/test/typecheck/shipfox-app.gen.ts +85 -0
- package/test/typecheck/types.tsx +18 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +1 -0
- package/tsconfig.test.json +12 -0
- package/vitest.config.ts +22 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {mkdtemp, realpath, rm, writeFile} from 'node:fs/promises';
|
|
2
|
+
import {tmpdir} from 'node:os';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {evaluateFeatures, invalidateFeatures} from './evaluate-features.js';
|
|
5
|
+
|
|
6
|
+
describe('evaluateFeatures', () => {
|
|
7
|
+
let directory: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
directory = await mkdtemp(join(tmpdir(), 'shipfox-client-shell-vite-'));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await rm(directory, {recursive: true, force: true});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('returns dependencies that were cached before the manifest reload', async () => {
|
|
18
|
+
const features = join(directory, 'features.ts');
|
|
19
|
+
const contribution = join(directory, 'contribution.ts');
|
|
20
|
+
await writeFile(
|
|
21
|
+
features,
|
|
22
|
+
`import {feature} from './contribution.ts';
|
|
23
|
+
export const features = [feature];`,
|
|
24
|
+
);
|
|
25
|
+
await writeFile(contribution, `export const feature = {id: 'acme.projects'};`);
|
|
26
|
+
|
|
27
|
+
await evaluateFeatures(features);
|
|
28
|
+
invalidateFeatures([await realpath(features)]);
|
|
29
|
+
|
|
30
|
+
const evaluated = await evaluateFeatures(features);
|
|
31
|
+
|
|
32
|
+
expect(evaluated.loadedFiles).toContain(await realpath(contribution));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {readFile, realpath} from 'node:fs/promises';
|
|
2
|
+
import {createRequire} from 'node:module';
|
|
3
|
+
import {resolve} from 'node:path';
|
|
4
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
5
|
+
import {createJiti} from 'jiti';
|
|
6
|
+
import type {ClientFeature} from '#contract.js';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const importPattern =
|
|
10
|
+
/(?:\b(?:import|export)\s+(?:[^'"]*?\s+from\s+)?|\bimport\s*\(\s*)['"]([^'"]+)['"]/g;
|
|
11
|
+
const nodeModulesPathPattern = /[\\/]node_modules[\\/]/;
|
|
12
|
+
|
|
13
|
+
export interface EvaluatedFeatures {
|
|
14
|
+
features: readonly ClientFeature[];
|
|
15
|
+
loadedFiles: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function invalidateFeatures(loadedFiles: Iterable<string>): void {
|
|
19
|
+
for (const file of loadedFiles) delete require.cache[file];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function loadedModuleGraph(entry: string): string[] {
|
|
23
|
+
const files = new Set<string>();
|
|
24
|
+
|
|
25
|
+
function visit(file: string): void {
|
|
26
|
+
if (files.has(file)) return;
|
|
27
|
+
files.add(file);
|
|
28
|
+
for (const child of require.cache[file]?.children ?? []) visit(child.filename);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
visit(entry);
|
|
32
|
+
return [...files];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function staticallyImportedModules(
|
|
36
|
+
entry: string,
|
|
37
|
+
jiti: ReturnType<typeof createJiti>,
|
|
38
|
+
): Promise<string[]> {
|
|
39
|
+
const files = new Set<string>();
|
|
40
|
+
|
|
41
|
+
async function visit(file: string): Promise<void> {
|
|
42
|
+
const path = file.startsWith('file:') ? fileURLToPath(file) : file;
|
|
43
|
+
if (path.startsWith('node:') || nodeModulesPathPattern.test(path) || files.has(path)) return;
|
|
44
|
+
const resolvedPath = await realpath(path).catch(() => path);
|
|
45
|
+
files.add(resolvedPath);
|
|
46
|
+
const source = await readFile(resolvedPath, 'utf8').catch(() => undefined);
|
|
47
|
+
if (!source) return;
|
|
48
|
+
|
|
49
|
+
for (const match of source.matchAll(importPattern)) {
|
|
50
|
+
const specifier = match[1];
|
|
51
|
+
if (!specifier) continue;
|
|
52
|
+
const dependency = jiti.esmResolve(specifier, {
|
|
53
|
+
parentURL: pathToFileURL(resolvedPath),
|
|
54
|
+
try: true,
|
|
55
|
+
});
|
|
56
|
+
if (dependency) await visit(dependency);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await visit(entry);
|
|
61
|
+
return [...files];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function evaluateFeatures(featuresModule: string): Promise<EvaluatedFeatures> {
|
|
65
|
+
const resolvedFeaturesModule = await realpath(resolve(featuresModule));
|
|
66
|
+
const jiti = createJiti(resolvedFeaturesModule, {interopDefault: false, tsconfigPaths: true});
|
|
67
|
+
let module: {default?: unknown; features?: unknown};
|
|
68
|
+
let loadedFiles: string[];
|
|
69
|
+
try {
|
|
70
|
+
module = jiti(resolvedFeaturesModule) as {default?: unknown; features?: unknown};
|
|
71
|
+
loadedFiles = [
|
|
72
|
+
...new Set([
|
|
73
|
+
...loadedModuleGraph(resolvedFeaturesModule),
|
|
74
|
+
...(await staticallyImportedModules(resolvedFeaturesModule, jiti)),
|
|
75
|
+
]),
|
|
76
|
+
];
|
|
77
|
+
} catch (error) {
|
|
78
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Failed to evaluate features module "${resolvedFeaturesModule}". Features modules must be Node-safe: ${message}`,
|
|
81
|
+
{cause: error},
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const features = module.features ?? module.default;
|
|
86
|
+
if (!Array.isArray(features)) {
|
|
87
|
+
throw new TypeError(
|
|
88
|
+
`Features module "${resolvedFeaturesModule}" must export a features array.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {features: features as readonly ClientFeature[], loadedFiles};
|
|
93
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import {composeRoutes} from '#compose/compose-routes.js';
|
|
4
|
+
import {navigationEntries, settingsEntries} from '#runtime/registries.js';
|
|
5
|
+
import {features} from '#test/fixtures/features.js';
|
|
6
|
+
import {generateAppModule} from './generate.js';
|
|
7
|
+
|
|
8
|
+
const goldenFile = fileURLToPath(
|
|
9
|
+
new URL('../../test/typecheck/shipfox-app.gen.ts', import.meta.url),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
describe('generateAppModule', () => {
|
|
13
|
+
test('matches the checked composition fixture', async () => {
|
|
14
|
+
const generated = generateAppModule({
|
|
15
|
+
routes: composeRoutes(features),
|
|
16
|
+
navigation: navigationEntries(features),
|
|
17
|
+
settingsSections: settingsEntries(features),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
await expect(readFile(goldenFile, 'utf8')).resolves.toBe(generated);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
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 route${index}Impl 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}Impl, ${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
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {shipfoxClientComposition} from './plugin.js';
|
|
@@ -0,0 +1,181 @@
|
|
|
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, ResolvedConfig} 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/not-route-impl.js': join(testDirectory, 'not-route-impl.ts'),
|
|
39
|
+
'#test/search-route-impl.js': join(testDirectory, 'search-route-impl.tsx'),
|
|
40
|
+
};
|
|
41
|
+
return files[source];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pluginContext() {
|
|
45
|
+
const watchedFiles: string[] = [];
|
|
46
|
+
return {
|
|
47
|
+
watchedFiles,
|
|
48
|
+
addWatchFile(file: string) {
|
|
49
|
+
watchedFiles.push(file);
|
|
50
|
+
},
|
|
51
|
+
resolve(source: string) {
|
|
52
|
+
const id = resolveRouteImplementation(source);
|
|
53
|
+
return Promise.resolve(id ? {id} : null);
|
|
54
|
+
},
|
|
55
|
+
environment: {
|
|
56
|
+
pluginContainer: {
|
|
57
|
+
resolveId(source: string) {
|
|
58
|
+
const id = resolveRouteImplementation(source);
|
|
59
|
+
return Promise.resolve(id ? {id} : null);
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function build(plugin: Plugin, context: ReturnType<typeof pluginContext>): Promise<void> {
|
|
67
|
+
if (!plugin.buildStart) {
|
|
68
|
+
throw new Error('Expected the composition plugin to implement buildStart.');
|
|
69
|
+
}
|
|
70
|
+
await (
|
|
71
|
+
hookHandler(plugin.buildStart) as unknown as (
|
|
72
|
+
this: typeof context,
|
|
73
|
+
options: BuildStartOptions,
|
|
74
|
+
) => Promise<void>
|
|
75
|
+
).call(context, {} as BuildStartOptions);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('shipfoxClientComposition', () => {
|
|
79
|
+
let directory: string;
|
|
80
|
+
|
|
81
|
+
beforeEach(async () => {
|
|
82
|
+
directory = await mkdtemp(join(tmpdir(), 'shipfox-client-shell-vite-'));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(async () => {
|
|
86
|
+
await rm(directory, {recursive: true, force: true});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('generates a static route tree and watches the manifest module graph', async () => {
|
|
90
|
+
const out = join(directory, 'shipfox-app.gen.ts');
|
|
91
|
+
const plugin = shipfoxClientComposition({features: fixtureFeatures, out});
|
|
92
|
+
const context = pluginContext();
|
|
93
|
+
configure(plugin);
|
|
94
|
+
|
|
95
|
+
await build(plugin, context);
|
|
96
|
+
|
|
97
|
+
await expect(readFile(out, 'utf8')).resolves.toContain(
|
|
98
|
+
'import route0Impl from "#test/search-route-impl.js";',
|
|
99
|
+
);
|
|
100
|
+
expect(context.watchedFiles).toContain(fixtureFeatures);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('fails the Vite build with the shared collision message', async () => {
|
|
104
|
+
const features = join(directory, 'features.ts');
|
|
105
|
+
await writeFile(
|
|
106
|
+
features,
|
|
107
|
+
`export const features = [
|
|
108
|
+
{id: 'shipfox.projects', routes: [{path: '/projects', parent: 'root', impl: '#test/default-route-impl.js'}]},
|
|
109
|
+
{id: 'acme.projects', routes: [{path: '/projects', parent: 'root', impl: '#test/default-route-impl.js'}]},
|
|
110
|
+
];`,
|
|
111
|
+
);
|
|
112
|
+
const plugin = shipfoxClientComposition({features, out: join(directory, 'shipfox-app.gen.ts')});
|
|
113
|
+
const context = pluginContext();
|
|
114
|
+
configure(plugin);
|
|
115
|
+
|
|
116
|
+
await expect(build(plugin, context)).rejects.toThrow(
|
|
117
|
+
'Route "/projects" is contributed by both features "shipfox.projects" and "acme.projects". Set override: true to replace it explicitly.',
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('rejects route implementations without a default export', async () => {
|
|
122
|
+
const features = join(directory, 'features.ts');
|
|
123
|
+
await writeFile(
|
|
124
|
+
features,
|
|
125
|
+
`export const features = [{id: 'acme.projects', routes: [{path: '/projects', parent: 'root', impl: '#test/not-route-impl.js'}]}];`,
|
|
126
|
+
);
|
|
127
|
+
const plugin = shipfoxClientComposition({features, out: join(directory, 'shipfox-app.gen.ts')});
|
|
128
|
+
const context = pluginContext();
|
|
129
|
+
configure(plugin);
|
|
130
|
+
|
|
131
|
+
await expect(build(plugin, context)).rejects.toThrow(
|
|
132
|
+
'Route implementation "#test/not-route-impl.js" for "/projects" must export default defineRoute(...).',
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('regenerates when a watched manifest changes', async () => {
|
|
137
|
+
const features = join(directory, 'features.ts');
|
|
138
|
+
const contribution = join(directory, 'contribution.ts');
|
|
139
|
+
const out = join(directory, 'shipfox-app.gen.ts');
|
|
140
|
+
await writeFile(
|
|
141
|
+
features,
|
|
142
|
+
`import {feature} from './contribution.ts';
|
|
143
|
+
export const features = [feature];`,
|
|
144
|
+
);
|
|
145
|
+
await writeFile(
|
|
146
|
+
contribution,
|
|
147
|
+
`export const feature = {id: 'acme.insights', routes: [{path: '/insights', parent: 'root', impl: '#test/default-route-impl.js'}]};`,
|
|
148
|
+
);
|
|
149
|
+
const plugin = shipfoxClientComposition({features, out});
|
|
150
|
+
const context = pluginContext();
|
|
151
|
+
configure(plugin);
|
|
152
|
+
|
|
153
|
+
await build(plugin, context);
|
|
154
|
+
expect(context.watchedFiles).toContain(await realpath(contribution));
|
|
155
|
+
await writeFile(
|
|
156
|
+
contribution,
|
|
157
|
+
`export const feature = {id: 'acme.reports', routes: [{path: '/reports', parent: 'root', impl: '#test/default-route-impl.js'}]};`,
|
|
158
|
+
);
|
|
159
|
+
const hotUpdateContext = {environment: context.environment};
|
|
160
|
+
await (
|
|
161
|
+
plugin.hotUpdate as unknown as (
|
|
162
|
+
this: typeof hotUpdateContext,
|
|
163
|
+
options: {
|
|
164
|
+
file: string;
|
|
165
|
+
server: {watcher: {add(file: string): void}};
|
|
166
|
+
},
|
|
167
|
+
) => Promise<void>
|
|
168
|
+
).call(hotUpdateContext, {
|
|
169
|
+
file: contribution,
|
|
170
|
+
server: {
|
|
171
|
+
watcher: {
|
|
172
|
+
add(file: string) {
|
|
173
|
+
context.watchedFiles.push(file);
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await expect(readFile(out, 'utf8')).resolves.toContain('path: "/reports"');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
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, invalidateFeatures} from './evaluate-features.js';
|
|
10
|
+
import {generateAppModule} from './generate.js';
|
|
11
|
+
|
|
12
|
+
const defaultExportPattern = /\bexport\s+default\b|\bexport\s*\{[\s\S]*?\bas\s+default\b[\s\S]*?\}/;
|
|
13
|
+
|
|
14
|
+
export interface ShipfoxClientCompositionOptions {
|
|
15
|
+
features: string;
|
|
16
|
+
out?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type RouteResolver = (source: string, importer?: string) => Promise<{id: string} | null>;
|
|
20
|
+
|
|
21
|
+
function hasDefaultExport(source: string): boolean {
|
|
22
|
+
return defaultExportPattern.test(source);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function shipfoxClientComposition({
|
|
26
|
+
features,
|
|
27
|
+
out = './src/shipfox-app.gen.ts',
|
|
28
|
+
}: ShipfoxClientCompositionOptions): Plugin {
|
|
29
|
+
let config: ResolvedConfig | undefined;
|
|
30
|
+
let watchedFiles = new Set<string>();
|
|
31
|
+
|
|
32
|
+
const outputPath = () => resolve(config?.root ?? process.cwd(), out);
|
|
33
|
+
const featuresPath = () => resolve(config?.root ?? process.cwd(), features);
|
|
34
|
+
|
|
35
|
+
async function assertDefaultRouteExports(
|
|
36
|
+
resolveRoute: RouteResolver,
|
|
37
|
+
routes: ReturnType<typeof composeRoutes>,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
for (const route of routes) {
|
|
40
|
+
const resolvedRoute = await resolveRoute(route.impl, outputPath());
|
|
41
|
+
if (!resolvedRoute) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Could not resolve route implementation "${route.impl}" for "${route.path}".`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const source = await readFile(resolvedRoute.id, 'utf8');
|
|
47
|
+
if (!hasDefaultExport(source)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Route implementation "${route.impl}" for "${route.path}" must export default defineRoute(...).`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function generate({
|
|
56
|
+
addWatchFile,
|
|
57
|
+
resolveRoute,
|
|
58
|
+
}: {
|
|
59
|
+
addWatchFile(file: string): void;
|
|
60
|
+
resolveRoute: RouteResolver;
|
|
61
|
+
}): Promise<void> {
|
|
62
|
+
invalidateFeatures(watchedFiles);
|
|
63
|
+
const evaluated = await evaluateFeatures(featuresPath());
|
|
64
|
+
const routes = composeRoutes(evaluated.features);
|
|
65
|
+
validateProviderIds(evaluated.features);
|
|
66
|
+
validateNavigation(
|
|
67
|
+
evaluated.features,
|
|
68
|
+
routes.map((route) => route.path),
|
|
69
|
+
);
|
|
70
|
+
validateSettingsSections(
|
|
71
|
+
evaluated.features,
|
|
72
|
+
routes.map((route) => route.path),
|
|
73
|
+
);
|
|
74
|
+
mergeConfigShapes(evaluated.features);
|
|
75
|
+
await assertDefaultRouteExports(resolveRoute, routes);
|
|
76
|
+
|
|
77
|
+
watchedFiles = new Set(evaluated.loadedFiles);
|
|
78
|
+
for (const file of watchedFiles) addWatchFile(file);
|
|
79
|
+
|
|
80
|
+
const output = generateAppModule({
|
|
81
|
+
routes,
|
|
82
|
+
navigation: navigationEntries(evaluated.features),
|
|
83
|
+
settingsSections: settingsEntries(evaluated.features),
|
|
84
|
+
});
|
|
85
|
+
const path = outputPath();
|
|
86
|
+
const existing = await readFile(path, 'utf8').catch(() => undefined);
|
|
87
|
+
if (existing === output) return;
|
|
88
|
+
await mkdir(dirname(path), {recursive: true});
|
|
89
|
+
await writeFile(path, output);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
name: 'shipfox-client-composition',
|
|
94
|
+
configResolved(resolvedConfig) {
|
|
95
|
+
config = resolvedConfig;
|
|
96
|
+
},
|
|
97
|
+
async buildStart() {
|
|
98
|
+
await generate({
|
|
99
|
+
addWatchFile: this.addWatchFile.bind(this),
|
|
100
|
+
resolveRoute: this.resolve.bind(this),
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
async hotUpdate({file, server}) {
|
|
104
|
+
const resolvedFile = await realpath(file).catch(() => resolve(file));
|
|
105
|
+
if (!watchedFiles.has(resolvedFile)) return;
|
|
106
|
+
await generate({
|
|
107
|
+
addWatchFile: server.watcher.add.bind(server.watcher),
|
|
108
|
+
resolveRoute: this.environment.pluginContainer.resolveId.bind(
|
|
109
|
+
this.environment.pluginContainer,
|
|
110
|
+
),
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|