Skip to content

Commit 5f34865

Browse files
committed
refactor: inline HTML strings
1 parent e6eb0fa commit 5f34865

File tree

6 files changed

+100
-99
lines changed

6 files changed

+100
-99
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ coverage
1515
# Generated assets by accuracy runs
1616
.accuracy
1717

18+
# Generated UI module (rebuilt by `pnpm build:ui`)
19+
src/ui/generated
20+
1821
.DS_Store
1922

2023
# Development tool files

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
"build:cjs": "tsc --project tsconfig.cjs.json",
6464
"build:ui": "vite build --config vite.ui.config.ts",
6565
"build:universal-package": "tsx scripts/createUniversalPackage.ts",
66-
"build": "pnpm run build:clean && concurrently \"pnpm run build:esm\" \"pnpm run build:cjs\" && pnpm run build:ui && pnpm run build:universal-package",
66+
"build": "pnpm run build:clean && pnpm run build:ui && concurrently \"pnpm run build:esm\" \"pnpm run build:cjs\" && pnpm run build:universal-package",
6767
"inspect": "pnpm run build && mcp-inspector -- dist/esm/index.js",
6868
"prettier": "prettier",
6969
"check": "concurrently \"pnpm run build\" \"pnpm run check:types\" \"pnpm run check:lint\" \"pnpm run check:format\" \"pnpm run check:dependencies\"",

src/tools/mongodb/metadata/listDatabases.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import type { OperationType } from "../../tool.js";
55
import { formatUntrustedData } from "../../tool.js";
66
import { ListDatabasesOutputSchema, type ListDatabasesOutput } from "../../../ui/components/ListDatabases/schema.js";
77

8-
// Re-export for consumers who need the schema/type
98
export { ListDatabasesOutputSchema, type ListDatabasesOutput };
109

1110
export class ListDatabasesTool extends MongoDBToolBase {

src/ui/registry/registry.ts

Lines changed: 23 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,15 @@
1-
import { readFileSync, existsSync } from "fs";
2-
import { join, dirname } from "path";
3-
import { fileURLToPath } from "url";
4-
import { uiMap } from "./uiMap.js";
5-
6-
/**
7-
* Get the directory of the current module, works in both ESM and CJS.
8-
*/
9-
function getCurrentDir(): string {
10-
if (typeof __dirname !== "undefined") {
11-
return __dirname;
12-
}
13-
return dirname(fileURLToPath(import.meta.url));
14-
}
15-
16-
/**
17-
* Find the package root by looking for package.json walking up from the current directory.
18-
*/
19-
function findPackageRoot(startDir: string): string {
20-
let dir = startDir;
21-
while (dir !== dirname(dir)) {
22-
if (existsSync(join(dir, "package.json"))) {
23-
return dir;
24-
}
25-
dir = dirname(dir);
26-
}
27-
return process.cwd();
28-
}
29-
30-
/**
31-
* Get the default UI dist path by finding the package root.
32-
*/
33-
function getDefaultUIDistPath(): string {
34-
const currentDir = getCurrentDir();
35-
const packageRoot = findPackageRoot(currentDir);
36-
return join(packageRoot, "dist", "ui");
37-
}
1+
import { uiHtml } from "../generated/uiHtml.js";
382

393
/**
404
* UI Registry that manages bundled UI HTML strings for tools.
5+
*
6+
* The default UIs are embedded at build time via the generated uiHtml module.
7+
* Custom UIs can be provided at runtime to override or extend the defaults.
418
*/
429
export class UIRegistry {
4310
private customUIs: Map<string, string> = new Map();
44-
private cache: Map<string, string> = new Map();
45-
private uiDistPath: string;
4611

4712
constructor(options?: { customUIs?: Record<string, string> }) {
48-
this.uiDistPath = getDefaultUIDistPath();
49-
5013
if (options?.customUIs) {
5114
for (const [toolName, html] of Object.entries(options.customUIs)) {
5215
this.customUIs.set(toolName, html);
@@ -56,35 +19,29 @@ export class UIRegistry {
5619

5720
/**
5821
* Get the UI HTML string for a tool.
59-
* Returns the custom UI if provided, otherwise loads the default from disk.
60-
* @param toolName The name of the tool
22+
* @param toolName The name of the tool (kebab-case, e.g., "list-databases")
6123
* @returns The HTML string, or undefined if no UI exists for this tool
6224
*/
6325
get(toolName: string): string | undefined {
64-
if (this.customUIs.has(toolName)) {
65-
return this.customUIs.get(toolName);
66-
}
67-
68-
const componentName = uiMap[toolName];
69-
if (!componentName) {
70-
return undefined;
71-
}
72-
73-
if (this.cache.has(toolName)) {
74-
return this.cache.get(toolName);
75-
}
26+
return this.customUIs.get(toolName) ?? uiHtml[toolName];
27+
}
7628

77-
const filePath = join(this.uiDistPath, `${componentName}.html`);
78-
if (!existsSync(filePath)) {
79-
return undefined;
80-
}
29+
/**
30+
* Check if a UI exists for a tool.
31+
* @param toolName The name of the tool
32+
* @returns True if a UI exists (custom or built-in)
33+
*/
34+
has(toolName: string): boolean {
35+
return this.customUIs.has(toolName) || toolName in uiHtml;
36+
}
8137

82-
try {
83-
const html = readFileSync(filePath, "utf-8");
84-
this.cache.set(toolName, html);
85-
return html;
86-
} catch {
87-
return undefined;
88-
}
38+
/**
39+
* Get all available tool names that have UIs.
40+
* @returns Array of tool names
41+
*/
42+
getAvailableTools(): string[] {
43+
const builtIn = Object.keys(uiHtml);
44+
const custom = Array.from(this.customUIs.keys());
45+
return [...new Set([...builtIn, ...custom])];
8946
}
9047
}

src/ui/registry/uiMap.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
/**
22
* Mapping from tool names to their UI component names.
3+
*
34
* Tool names use kebab-case (e.g., 'list-databases')
45
* Component names use PascalCase (e.g., 'ListDatabases')
56
*
67
* The component name corresponds to the folder in src/ui/components/
7-
* The registry handles resolving this to the built HTML file.
8+
* This mapping is used at BUILD TIME to generate the uiHtml module.
89
*/
910
export const uiMap: Record<string, string> = {
1011
"list-databases": "ListDatabases",

vite.ui.config.ts

Lines changed: 71 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,57 +2,46 @@ import { defineConfig, Plugin, PluginOption } from "vite";
22
import react from "@vitejs/plugin-react";
33
import { viteSingleFile } from "vite-plugin-singlefile";
44
import { nodePolyfills } from "vite-plugin-node-polyfills";
5-
import { readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
5+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
66
import { join, resolve } from "path";
7+
import { uiMap } from "./src/ui/registry/uiMap.js";
78

89
const componentsDir = resolve(__dirname, "src/ui/components");
910
// Use node_modules/.cache for generated HTML entries - these are build artifacts, not source files
1011
const entriesDir = resolve(__dirname, "node_modules/.cache/mongodb-mcp-server/ui-entries");
1112
const templatePath = resolve(__dirname, "src/ui/build/template.html");
1213
const mountPath = resolve(__dirname, "src/ui/build/mount.tsx");
14+
const generatedModulePath = resolve(__dirname, "src/ui/generated/uiHtml.ts");
15+
const uiDistPath = resolve(__dirname, "dist/ui");
1316

14-
/**
15-
* Discover all component directories in src/ui/components/
16-
* Each directory should have an index.ts that exports the component
17-
*/
18-
function discoverComponents(): string[] {
19-
const components: string[] = [];
20-
21-
try {
22-
const dirs = readdirSync(componentsDir);
23-
for (const dir of dirs) {
24-
const dirPath = join(componentsDir, dir);
25-
if (statSync(dirPath).isDirectory()) {
26-
// Check if index.ts exists
27-
const indexPath = join(dirPath, "index.ts");
28-
if (existsSync(indexPath)) {
29-
components.push(dir);
30-
}
31-
}
32-
}
33-
} catch {
34-
console.warn("No components directory found or error reading it");
35-
}
36-
37-
return components;
38-
}
17+
// Unique component names from uiMap - only these will be built
18+
const components = [...new Set(Object.values(uiMap))];
3919

4020
/**
41-
* Vite plugin that generates HTML entry files for each component
42-
* based on the template.html file
21+
* Vite plugin that generates HTML entry files for each mapped component
22+
* based on the template.html file.
23+
*
24+
* Only builds components that are referenced in uiMap.
4325
*/
4426
function generateHtmlEntries(): Plugin {
4527
return {
4628
name: "generate-html-entries",
4729
buildStart() {
48-
const components = discoverComponents();
4930
const template = readFileSync(templatePath, "utf-8");
5031

5132
if (!existsSync(entriesDir)) {
5233
mkdirSync(entriesDir, { recursive: true });
5334
}
5435

5536
for (const componentName of components) {
37+
// Verify the component exists
38+
const componentPath = join(componentsDir, componentName, "index.ts");
39+
if (!existsSync(componentPath)) {
40+
throw new Error(
41+
`Component "${componentName}" referenced in uiMap but not found at ${componentPath}`
42+
);
43+
}
44+
5645
const html = template
5746
.replace("{{COMPONENT_NAME}}", componentName)
5847
.replace("{{TITLE}}", componentName.replace(/([A-Z])/g, " $1").trim()) // "ListDatabases" -> "List Databases"
@@ -66,7 +55,58 @@ function generateHtmlEntries(): Plugin {
6655
};
6756
}
6857

69-
const components = discoverComponents();
58+
/**
59+
* Vite plugin that generates the uiHtml.ts module after the build completes.
60+
* This embeds all built HTML strings into a TypeScript module so they can be
61+
* imported at runtime.
62+
*
63+
* Uses the uiMap from src/ui/registry/uiMap.ts to map tool names to component HTML files.
64+
*/
65+
function generateUIModule(): Plugin {
66+
return {
67+
name: "generate-ui-module",
68+
closeBundle() {
69+
if (!existsSync(uiDistPath)) {
70+
console.warn("[generate-ui-module] dist/ui not found, skipping module generation");
71+
return;
72+
}
73+
74+
const entries: Record<string, string> = {};
75+
76+
// Use uiMap to determine which tools get which UI
77+
for (const [toolName, componentName] of Object.entries(uiMap)) {
78+
const htmlFile = join(uiDistPath, `${componentName}.html`);
79+
if (!existsSync(htmlFile)) {
80+
console.warn(
81+
`[generate-ui-module] HTML file not found for component "${componentName}" (tool: "${toolName}")`
82+
);
83+
continue;
84+
}
85+
const html = readFileSync(htmlFile, "utf-8");
86+
entries[toolName] = html;
87+
}
88+
89+
const moduleContent = `/**
90+
* AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
91+
*
92+
* This file is generated by the UI build process (vite build --config vite.ui.config.ts).
93+
* It contains the bundled HTML strings for each UI component, keyed by tool name.
94+
*
95+
* To add a new UI:
96+
* 1. Create a component in src/ui/components/YourComponent/
97+
* 2. Add the mapping in src/ui/registry/uiMap.ts
98+
* 3. Run \`pnpm build:ui\` to regenerate this file
99+
*/
100+
export const uiHtml: Record<string, string> = ${JSON.stringify(entries, null, 4)};
101+
`;
102+
103+
writeFileSync(generatedModulePath, moduleContent);
104+
console.log(
105+
`[generate-ui-module] Generated uiHtml.ts with ${Object.keys(entries).length} UI(s): ${Object.keys(entries).join(", ")}`
106+
);
107+
},
108+
};
109+
}
70110

71111
export default defineConfig({
72112
root: entriesDir,
@@ -82,6 +122,7 @@ export default defineConfig({
82122
viteSingleFile({
83123
removeViteModuleLoader: true,
84124
}),
125+
generateUIModule(),
85126
],
86127
build: {
87128
outDir: resolve(__dirname, "dist/ui"),

0 commit comments

Comments
 (0)