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('dist/view.html', 'utf8'), // the view above, built into one page
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
- [`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, the build that inlines it into one page, and this server, the one behind `https://ngx-json-render.vercel.app/mcp`.
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.5",
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
+ }