ngx-json-render 0.9.5 → 0.9.6
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/README.md
CHANGED
|
@@ -746,7 +746,7 @@ import { catalog } from './catalog'; // defineCatalog(schema, …) from ngx-json
|
|
|
746
746
|
|
|
747
747
|
const server = createRenderUiServer({
|
|
748
748
|
catalog,
|
|
749
|
-
html: readFileSync('
|
|
749
|
+
html: readFileSync(new URL('./view.html', import.meta.url), 'utf8'), // the view above, built into one page
|
|
750
750
|
name: 'my-app',
|
|
751
751
|
version: '1.0.0',
|
|
752
752
|
});
|
|
@@ -764,7 +764,24 @@ What it does on top of upstream's `createMcpApp` from `@json-render/mcp`, whose
|
|
|
764
764
|
|
|
765
765
|
Options: `name`, `version`, `toolName` (default `render-ui`), `title`, `description`, `csp` (the origins the view may load from and connect to; left out, the host allows none) and `widgetDomain` (the view's origin for ChatGPT's plugin directory). If your catalog has a `sendMessage` action, the description tells the model how to use it; the view's handler for it calls `mcp.sendMessage()`.
|
|
766
766
|
|
|
767
|
-
|
|
767
|
+
### Building it
|
|
768
|
+
|
|
769
|
+
A host loads the view as one HTML document with nowhere to fetch chunks from, so the Angular build has to be folded into a single page. The `ngx-json-render:mcp-app` builder does that, and bundles the server next to it. Give the view its own application project (`ng generate application mcp-view`), then add a target to it in `angular.json`:
|
|
770
|
+
|
|
771
|
+
```json
|
|
772
|
+
"mcp": {
|
|
773
|
+
"builder": "ngx-json-render:mcp-app",
|
|
774
|
+
"options": {
|
|
775
|
+
"buildTarget": "mcp-view:build:production",
|
|
776
|
+
"outputPath": "dist/mcp-app",
|
|
777
|
+
"server": "projects/mcp-view/server.ts"
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
`ng run mcp-view:mcp` runs the application build, then writes `dist/mcp-app/view.html` with every script and stylesheet inlined, and the fonts and images the stylesheets use as data URLs, and `dist/mcp-app/server.mjs` with its dependencies bundled in, so `node dist/mcp-app/server.mjs` runs anywhere without `node_modules`. Files the page cannot reach, such as an image referenced only from a template, are listed as a warning: point at them from a stylesheet or from an `https:` origin in `csp`. The server bundle is not type-checked; run `tsc --noEmit` on it first if you want that. Options: `server` (leave it out to build the view only), `tsConfig` for the server, and `externalPackages: true` to keep `node_modules` packages out of `server.mjs`.
|
|
783
|
+
|
|
784
|
+
[`projects/mcp-app`](https://github.com/shteynu/ngx-json-render/tree/main/projects/mcp-app) is a complete example with the Material catalog: the view, built with this builder, and this server, the one behind `https://ngx-json-render.vercel.app/mcp`.
|
|
768
785
|
|
|
769
786
|
## Devtools
|
|
770
787
|
|
|
@@ -1208,6 +1225,8 @@ MCP Apps (`ngx-json-render/mcp`): `injectJsonRenderApp`, `parseSpecFromToolResul
|
|
|
1208
1225
|
|
|
1209
1226
|
MCP App server (`ngx-json-render/mcp/server`): `createRenderUiServer`, `handleRenderUiRequest`, `renderUiDescription`, `specInputSchema`, `specOutputSchema`, `specProblems`, `DESCRIPTION_LIMIT`, and the types `RenderUiServerOptions`, `RenderUiDescriptionOptions`. No Angular; the same optional peers.
|
|
1210
1227
|
|
|
1228
|
+
MCP App builder (`ngx-json-render:mcp-app`): the view's application build as one `view.html`, and the server bundled into `server.mjs`.
|
|
1229
|
+
|
|
1211
1230
|
Router (`ngx-json-render/router`): `injectRouterNavigate`, and the type `RouterNavigateOptions`. Needs the optional peer `@angular/router`, which an Angular app on the router already has.
|
|
1212
1231
|
|
|
1213
1232
|
AG-UI (`ngx-json-render/ag-ui`): `injectAgentUI`, `applyAgUiEvent`, `surfacesFromMessages`, `isJsonRenderSpec`, `JsonRenderActivity` (`<json-render-activity>`), `jsonRenderActivityRenderer`, `JSON_RENDER_ACTIVITY_TYPE`, and the types `AgUiAgent`, `AgUiEvent`, `AgUiMessage`, `AgUiSubscriber`, `AgUiSurface`, `AgentUIOptions`, `AgentUIReturn`, `JsonRenderActivityRendererConfig`, `JsonRenderActivityRendererOptions`. No peer: any agent and event of the right shape will do.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ngx-json-render",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"description": "Generative UI for Angular: a json-render renderer that streams AI-generated JSON specs into real Angular components with signals.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"angular",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"sideEffects": false,
|
|
54
54
|
"schematics": "./schematics/collection.json",
|
|
55
|
+
"builders": "./schematics/builders.json",
|
|
55
56
|
"ng-add": {
|
|
56
57
|
"save": "dependencies"
|
|
57
58
|
},
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../node_modules/@angular-devkit/architect/src/builders-schema.json",
|
|
3
|
+
"builders": {
|
|
4
|
+
"mcp-app": {
|
|
5
|
+
"implementation": "./mcp-app-builder/index.cjs",
|
|
6
|
+
"schema": "./mcp-app-builder/schema.json",
|
|
7
|
+
"description": "Builds an MCP App: the view as one view.html, the server as server.mjs."
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildMcpApp = buildMcpApp;
|
|
4
|
+
const architect_1 = require("@angular-devkit/architect");
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_module_1 = require("node:module");
|
|
7
|
+
const node_path_1 = require("node:path");
|
|
8
|
+
const inline_cjs_1 = require("./inline.cjs");
|
|
9
|
+
/**
|
|
10
|
+
* `ngx-json-render:mcp-app`: runs the view's application build, folds its
|
|
11
|
+
* browser output into one `view.html` (an MCP Apps host loads the view as a
|
|
12
|
+
* single document), and bundles the server entry, if there is one, into
|
|
13
|
+
* `server.mjs` next to it.
|
|
14
|
+
*/
|
|
15
|
+
async function buildMcpApp(options, context) {
|
|
16
|
+
const root = context.workspaceRoot;
|
|
17
|
+
const target = (0, architect_1.targetFromTargetString)(options.buildTarget, context.target?.project);
|
|
18
|
+
const run = await context.scheduleTarget(target);
|
|
19
|
+
const result = await run.result;
|
|
20
|
+
await run.stop();
|
|
21
|
+
if (!result.success)
|
|
22
|
+
return result;
|
|
23
|
+
const esbuild = loadEsbuild(root);
|
|
24
|
+
const browserDir = await browserOutput(context, target);
|
|
25
|
+
const view = await (0, inline_cjs_1.inlineView)(browserDir, async (entry) => {
|
|
26
|
+
const { outputFiles } = await esbuild.build({
|
|
27
|
+
entryPoints: [entry],
|
|
28
|
+
bundle: true,
|
|
29
|
+
format: 'esm',
|
|
30
|
+
minify: true,
|
|
31
|
+
write: false,
|
|
32
|
+
logLevel: 'warning',
|
|
33
|
+
});
|
|
34
|
+
return outputFiles[0].text;
|
|
35
|
+
});
|
|
36
|
+
const out = (0, node_path_1.resolve)(root, options.outputPath);
|
|
37
|
+
(0, node_fs_1.mkdirSync)(out, { recursive: true });
|
|
38
|
+
const viewPath = (0, node_path_1.join)(out, 'view.html');
|
|
39
|
+
(0, node_fs_1.writeFileSync)(viewPath, view.html);
|
|
40
|
+
context.logger.info(`Built ${(0, node_path_1.relative)(root, viewPath)} (${Math.round(view.html.length / 1024)} kB)`);
|
|
41
|
+
if (view.leftOut.length > 0) {
|
|
42
|
+
context.logger.warn(`Not in view.html, so the host cannot load them: ${view.leftOut.join(', ')}. ` +
|
|
43
|
+
'Reference assets from a stylesheet (they are inlined) or from an https: URL the view CSP allows.');
|
|
44
|
+
}
|
|
45
|
+
if (options.server) {
|
|
46
|
+
const serverPath = (0, node_path_1.join)(out, 'server.mjs');
|
|
47
|
+
await esbuild.build({
|
|
48
|
+
entryPoints: [(0, node_path_1.resolve)(root, options.server)],
|
|
49
|
+
outfile: serverPath,
|
|
50
|
+
bundle: true,
|
|
51
|
+
platform: 'node',
|
|
52
|
+
format: 'esm',
|
|
53
|
+
target: 'node20',
|
|
54
|
+
tsconfig: options.tsConfig ? (0, node_path_1.resolve)(root, options.tsConfig) : undefined,
|
|
55
|
+
packages: options.externalPackages ? 'external' : undefined,
|
|
56
|
+
// The view as text, for a server that imports it rather than reading
|
|
57
|
+
// the file next to it.
|
|
58
|
+
loader: { '.html': 'text' },
|
|
59
|
+
// Bundled CommonJS dependencies may call require(); give the ES module
|
|
60
|
+
// one.
|
|
61
|
+
banner: options.externalPackages
|
|
62
|
+
? undefined
|
|
63
|
+
: {
|
|
64
|
+
js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);",
|
|
65
|
+
},
|
|
66
|
+
logLevel: 'warning',
|
|
67
|
+
});
|
|
68
|
+
context.logger.info(`Built ${(0, node_path_1.relative)(root, serverPath)}`);
|
|
69
|
+
}
|
|
70
|
+
return { success: true, outputPath: out };
|
|
71
|
+
}
|
|
72
|
+
/** Where the application build wrote its browser files. */
|
|
73
|
+
async function browserOutput(context, target) {
|
|
74
|
+
const options = await context.getTargetOptions(target);
|
|
75
|
+
const outputPath = options['outputPath'];
|
|
76
|
+
const base = typeof outputPath === 'string'
|
|
77
|
+
? outputPath
|
|
78
|
+
: (outputPath?.base ?? (0, node_path_1.join)('dist', target.project));
|
|
79
|
+
const browser = typeof outputPath === 'object' && outputPath.browser !== undefined
|
|
80
|
+
? outputPath.browser
|
|
81
|
+
: 'browser';
|
|
82
|
+
return (0, node_path_1.resolve)(context.workspaceRoot, base, browser);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* esbuild, from `@angular/build` (which every application build already
|
|
86
|
+
* installs at a version it was tested with), or else from the workspace.
|
|
87
|
+
*/
|
|
88
|
+
function loadEsbuild(root) {
|
|
89
|
+
const workspace = (0, node_module_1.createRequire)((0, node_path_1.join)(root, 'package.json'));
|
|
90
|
+
try {
|
|
91
|
+
const build = (0, node_path_1.dirname)(workspace.resolve('@angular/build/package.json'));
|
|
92
|
+
return (0, node_module_1.createRequire)((0, node_path_1.join)(build, 'package.json'))('esbuild');
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return workspace('esbuild');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
exports.default = (0, architect_1.createBuilder)(buildMcpApp);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.inlineView = inlineView;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const MIME = {
|
|
7
|
+
'.woff2': 'font/woff2',
|
|
8
|
+
'.woff': 'font/woff',
|
|
9
|
+
'.ttf': 'font/ttf',
|
|
10
|
+
'.otf': 'font/otf',
|
|
11
|
+
'.png': 'image/png',
|
|
12
|
+
'.jpg': 'image/jpeg',
|
|
13
|
+
'.jpeg': 'image/jpeg',
|
|
14
|
+
'.gif': 'image/gif',
|
|
15
|
+
'.webp': 'image/webp',
|
|
16
|
+
'.avif': 'image/avif',
|
|
17
|
+
'.svg': 'image/svg+xml',
|
|
18
|
+
'.ico': 'image/x-icon',
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The browser output of an Angular application build as one HTML document.
|
|
22
|
+
* MCP Apps hosts load a view's `ui://` resource as a single document with no
|
|
23
|
+
* origin to fetch from, so every module script (main, polyfills, and the
|
|
24
|
+
* chunks they import) is bundled and inlined, every local stylesheet is
|
|
25
|
+
* inlined, and fonts and images the stylesheets point at become data URLs.
|
|
26
|
+
* External URLs stay as they are; the view's CSP has to allow them.
|
|
27
|
+
*/
|
|
28
|
+
async function inlineView(browserDir, bundle) {
|
|
29
|
+
const indexPath = (0, node_path_1.join)(browserDir, 'index.html');
|
|
30
|
+
if (!(0, node_fs_1.existsSync)(indexPath)) {
|
|
31
|
+
throw new Error(`No index.html in ${browserDir}. Is the build target an @angular/build:application build?`);
|
|
32
|
+
}
|
|
33
|
+
let html = (0, node_fs_1.readFileSync)(indexPath, 'utf8');
|
|
34
|
+
const used = new Set(['index.html']);
|
|
35
|
+
// Preloads point at chunks that are about to be inlined.
|
|
36
|
+
html = html.replace(/<link\b[^>]*\brel="modulepreload"[^>]*>/g, '');
|
|
37
|
+
// A view lives in an iframe, which shows no icon; `ng new` links one.
|
|
38
|
+
html = html.replace(/<link\b[^>]*\brel="(?:shortcut )?icon"[^>]*>\s*/g, (tag) => {
|
|
39
|
+
const href = attribute(tag, 'href');
|
|
40
|
+
const file = href && local(browserDir, href);
|
|
41
|
+
if (!file)
|
|
42
|
+
return tag;
|
|
43
|
+
used.add((0, node_path_1.relative)(browserDir, file));
|
|
44
|
+
return '';
|
|
45
|
+
});
|
|
46
|
+
for (const tag of html.match(/<script\b[^>]*\bsrc="[^"]*"[^>]*><\/script>/g) ?? []) {
|
|
47
|
+
const src = attribute(tag, 'src');
|
|
48
|
+
const file = src && local(browserDir, src);
|
|
49
|
+
if (!file)
|
|
50
|
+
continue;
|
|
51
|
+
used.add((0, node_path_1.relative)(browserDir, file));
|
|
52
|
+
const isModule = attribute(tag, 'type') === 'module';
|
|
53
|
+
const code = isModule ? await bundle(file) : (0, node_fs_1.readFileSync)(file, 'utf8');
|
|
54
|
+
html = html.replace(tag, () => `<script${isModule ? ' type="module"' : ''}>${escapeTag(code, 'script')}</script>`);
|
|
55
|
+
}
|
|
56
|
+
// Angular may load the stylesheet as `media="print" onload=…` with a
|
|
57
|
+
// <noscript> fallback, so it does not block the first paint.
|
|
58
|
+
const stylesheet = /<link\b[^>]*\brel="stylesheet"[^>]*>(\s*<noscript>[\s\S]*?<\/noscript>)?/g;
|
|
59
|
+
for (const [match] of html.matchAll(stylesheet)) {
|
|
60
|
+
const href = attribute(match, 'href');
|
|
61
|
+
const file = href && local(browserDir, href);
|
|
62
|
+
if (!file)
|
|
63
|
+
continue;
|
|
64
|
+
used.add((0, node_path_1.relative)(browserDir, file));
|
|
65
|
+
const css = inlineUrls((0, node_fs_1.readFileSync)(file, 'utf8'), file, browserDir, used);
|
|
66
|
+
html = html.replace(match, () => `<style>${escapeTag(css, 'style')}</style>`);
|
|
67
|
+
}
|
|
68
|
+
// Inline <style> blocks (critical CSS) can point at media files too.
|
|
69
|
+
html = html.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/g, (_, open, css, close) => open + inlineUrls(css, indexPath, browserDir, used) + close);
|
|
70
|
+
const leftOut = files(browserDir)
|
|
71
|
+
.map((file) => (0, node_path_1.relative)(browserDir, file))
|
|
72
|
+
.filter((file) => !used.has(file) && !isBuildArtifact(file));
|
|
73
|
+
return { html, leftOut };
|
|
74
|
+
}
|
|
75
|
+
/** `url(...)` references to files of the build, as data URLs. */
|
|
76
|
+
function inlineUrls(css, from, browserDir, used) {
|
|
77
|
+
return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (match, quote, url) => {
|
|
78
|
+
const base = (0, node_path_1.join)(from, '..');
|
|
79
|
+
const file = local(base, url) ?? local(browserDir, url);
|
|
80
|
+
const mime = file && MIME[(0, node_path_1.extname)(file).toLowerCase()];
|
|
81
|
+
if (!file || !mime)
|
|
82
|
+
return match;
|
|
83
|
+
used.add((0, node_path_1.relative)(browserDir, file));
|
|
84
|
+
const data = (0, node_fs_1.readFileSync)(file).toString('base64');
|
|
85
|
+
return `url(${quote}data:${mime};base64,${data}${quote})`;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** The file `url` names under `dir`, or undefined for anything external. */
|
|
89
|
+
function local(dir, url) {
|
|
90
|
+
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(url))
|
|
91
|
+
return undefined;
|
|
92
|
+
const path = decodeURIComponent(url.split(/[?#]/)[0]).replace(/^\/+/, '');
|
|
93
|
+
const file = (0, node_path_1.resolve)(dir, path);
|
|
94
|
+
if (file !== (0, node_path_1.resolve)(dir) && !file.startsWith((0, node_path_1.resolve)(dir) + node_path_1.sep)) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
return (0, node_fs_1.existsSync)(file) && (0, node_fs_1.statSync)(file).isFile() ? file : undefined;
|
|
98
|
+
}
|
|
99
|
+
function attribute(tag, name) {
|
|
100
|
+
return new RegExp(`\\b${name}="([^"]*)"`).exec(tag)?.[1];
|
|
101
|
+
}
|
|
102
|
+
/** Keeps `</script>` or `</style>` inside the code from closing the tag. */
|
|
103
|
+
function escapeTag(code, tag) {
|
|
104
|
+
return code.replace(new RegExp(`</${tag}`, 'gi'), `<\\/${tag}`);
|
|
105
|
+
}
|
|
106
|
+
/** Chunks and maps the inlined scripts already carry, and build metadata. */
|
|
107
|
+
function isBuildArtifact(file) {
|
|
108
|
+
return (/\.(?:js|mjs|map)$/.test(file) ||
|
|
109
|
+
file === '3rdpartylicenses.txt' ||
|
|
110
|
+
file === 'prerendered-routes.json');
|
|
111
|
+
}
|
|
112
|
+
function files(dir) {
|
|
113
|
+
return (0, node_fs_1.readdirSync)(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
114
|
+
const path = (0, node_path_1.join)(dir, entry.name);
|
|
115
|
+
return entry.isDirectory() ? files(path) : [path];
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema",
|
|
3
|
+
"title": "ngx-json-render MCP App builder",
|
|
4
|
+
"description": "Builds an MCP App view into one view.html and bundles its server into server.mjs.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"buildTarget": {
|
|
8
|
+
"type": "string",
|
|
9
|
+
"description": "The view's @angular/build:application target, such as \"my-app:build:production\" or \":build:production\" for this project."
|
|
10
|
+
},
|
|
11
|
+
"outputPath": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"description": "Directory for view.html and server.mjs, relative to the workspace root."
|
|
14
|
+
},
|
|
15
|
+
"server": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "The server's entry file, bundled into server.mjs. Leave it out to build the view only."
|
|
18
|
+
},
|
|
19
|
+
"tsConfig": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"description": "The tsconfig esbuild reads for the server (paths, decorators). By default, the nearest one to each file."
|
|
22
|
+
},
|
|
23
|
+
"externalPackages": {
|
|
24
|
+
"type": "boolean",
|
|
25
|
+
"default": false,
|
|
26
|
+
"description": "Leave node_modules packages out of server.mjs, to be installed next to it. By default the server is one self-contained file."
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"required": ["buildTarget", "outputPath"],
|
|
30
|
+
"additionalProperties": false
|
|
31
|
+
}
|