ngx-json-render 0.8.2 → 0.9.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/README.md CHANGED
@@ -250,6 +250,38 @@ export class Page {
250
250
  }
251
251
  ```
252
252
 
253
+ ### Defaults for the whole app
254
+
255
+ A chat renders a spec in every message, and binding the same registry and handlers on each one gets old. `provideJsonRender` sets them once:
256
+
257
+ ```ts
258
+ bootstrapApplication(App, {
259
+ providers: [
260
+ provideJsonRender(() => {
261
+ const orders = inject(OrderService); // the function form runs in an injection context
262
+ return {
263
+ registry,
264
+ catalog,
265
+ handlers: { cancelOrder: (params) => orders.cancel(params['id']) },
266
+ };
267
+ }),
268
+ ],
269
+ });
270
+ ```
271
+
272
+ ```html
273
+ <json-render [spec]="m.spec" />
274
+ ```
275
+
276
+ Every option input can be provided: `registry`, `fallback`, `validate`, `renderLimits`, `catalog`, `handlers`, `onAction`, `navigate`, `validationFunctions`, `functions` and `directives`. Per-instance inputs (`spec`, `loading`, `state`, `store`) cannot. The rules:
277
+
278
+ - An input bound on the element wins over the provided value. One left `null` or `undefined` falls through to it.
279
+ - `handlers`, `functions`, `validationFunctions` and `directives` merge by name instead, the element's entry winning a name both define. A chat can provide app-wide handlers and still add one for a single message.
280
+ - Everything else replaces. A `registry` belongs to its catalog, so two are never mixed behind your back; to combine them, spread them yourself (`{ ...registry, ...extraRegistry }`).
281
+ - It works in application, route and component `providers`. A provider below another extends it by the same rules.
282
+
283
+ With no registry bound and none provided, the renderer throws, naming both ways to supply one.
284
+
253
285
  ## Streaming a UI from an LLM
254
286
 
255
287
  Specs stream as JSONL patch lines (RFC 6902). Render partial specs as they arrive — the renderer tolerates missing elements while `loading` is true:
@@ -273,6 +305,40 @@ export class GeneratePage {
273
305
  }
274
306
  ```
275
307
 
308
+ ### Placeholders while props stream
309
+
310
+ An element can arrive before its props do: a Card with no `title` yet, a Text
311
+ with no `content`. By default the component mounts anyway and renders what it
312
+ has. Pass the `catalog` and the renderer waits instead: while `loading` is
313
+ true, an element whose props do not pass its component's schema shows its
314
+ registry entry's `fallback`, or nothing, and the component mounts once they
315
+ do.
316
+
317
+ ```ts
318
+ export const { registry } = defineRegistry(catalog, {
319
+ components: {
320
+ Card: { component: CardComponent, fallback: CardSkeleton },
321
+ Text: TextComponent, // nothing on screen until `content` arrives
322
+ },
323
+ });
324
+ ```
325
+
326
+ ```html
327
+ <json-render [spec]="ui.spec()" [registry]="registry" [catalog]="catalog" [loading]="ui.isStreaming()" />
328
+ ```
329
+
330
+ - The fallback gets the same render context as the component: it can read
331
+ the props that have arrived, and a `<jr-children />` in it lays out the
332
+ children that have.
333
+ - Props are checked as the spec writes them. `{ "$state": "/title" }` counts
334
+ as arrived, whatever it resolves to.
335
+ - Once an element's component has mounted it stays, even if a later patch
336
+ takes a prop away again.
337
+ - When `loading` turns false every element mounts, valid or not; reporting
338
+ what is wrong with the finished spec is `validate`'s job.
339
+ - Without a `catalog`, or for a type the catalog has no props schema for,
340
+ nothing is held back.
341
+
276
342
  ### The server side
277
343
 
278
344
  `injectUIStream` POSTs `{ prompt, context, currentSpec }` to your endpoint and expects the response body to be SpecStream JSONL — one RFC 6902 patch per line. Any server that can stream text works; with the [AI SDK](https://ai-sdk.dev) it's a few lines — `catalog.prompt()` teaches the model your component vocabulary and the patch protocol:
@@ -357,15 +423,16 @@ import { JsonRenderer, injectChatUI } from 'ngx-json-render';
357
423
  @for (m of chat.messages(); track m.id) {
358
424
  <p>{{ m.text }}</p>
359
425
  @if (m.spec) {
360
- <json-render [spec]="m.spec" [registry]="registry" />
426
+ <json-render [spec]="m.spec" />
361
427
  }
362
428
  }
363
429
  <button (click)="chat.send('show me revenue for the quarter')">Ask</button>
364
430
  `,
365
431
  })
366
432
  export class ChatPage {
433
+ // The registry comes from provideJsonRender({ registry }) in the app's
434
+ // providers: see "Defaults for the whole app".
367
435
  readonly chat = injectChatUI({ api: '/api/chat' });
368
- readonly registry = registry;
369
436
  }
370
437
  ```
371
438
 
@@ -744,6 +811,37 @@ value it derives updates when that state does.
744
811
  `projects/ngx-json-render/src/lib/directives.spec.ts` renders every one of
745
812
  them through the renderer, which is what keeps this paragraph honest.
746
813
 
814
+ ### Switching language
815
+
816
+ Where a spec's text comes from decides how it follows a language switch.
817
+
818
+ **Keys, translated on the client.** A spec that says
819
+ `{ "$t": "greeting", "params": { "name": { "$state": "/name" } } }` instead of
820
+ the words themselves switches in place. Derive `directives` from your locale
821
+ signal, and every prop re-resolves when it changes, with no new spec and
822
+ nothing remounted:
823
+
824
+ ```ts
825
+ readonly locale = signal('en');
826
+ readonly directives = computed(() => [
827
+ ...standardDirectives,
828
+ createI18nDirective({ locale: this.locale(), messages }),
829
+ ]);
830
+ ```
831
+
832
+ `$format` takes a `locale` the same way, for numbers, currency and dates.
833
+
834
+ **Words, written by the model.** Most generated UIs are this kind: the model
835
+ writes "Save" or "Сохранить" straight into the props. Tell it the language
836
+ through `context` (`ui.send(prompt, { context: { locale } })`) and say what to
837
+ do with it in your server's prompt. Text already on screen stays in the
838
+ language it was written in; switching means asking again, and
839
+ `previousSpec` keeps the layout while the model rewrites the words.
840
+
841
+ Text inside your catalog components is your app's own i18n, as it would be
842
+ anywhere else. The confirm dialog's two default words are `JR_CONFIRM_LABELS`
843
+ (see [Confirmation dialogs](#confirmation-dialogs)).
844
+
747
845
  ## State
748
846
 
749
847
  Each `<json-render>` owns a state store (JSON Pointer addressed). Seeding order: `store` input (controlled) → `state` input → `spec.state`.
@@ -900,7 +998,26 @@ rather than the one holding session, entitlement or billing state.
900
998
  **A spec chooses the navigation target.** `onSuccess: { navigate }` passes its
901
999
  string to the `navigate` callback you provide, verbatim. Treat it as untrusted:
902
1000
  match it against known routes, and never hand it to `window.location` or
903
- `router.navigateByUrl` unchecked.
1001
+ `router.navigateByUrl` unchecked. `ngx-json-render/router` does the checking
1002
+ for an app on the Angular `Router`:
1003
+
1004
+ ```ts
1005
+ import { injectRouterNavigate } from 'ngx-json-render/router';
1006
+
1007
+ provideJsonRender(() => ({
1008
+ registry,
1009
+ navigate: injectRouterNavigate({ allow: ['/thanks', /^\/orders\/\d+$/] }),
1010
+ }));
1011
+ ```
1012
+
1013
+ It passes a path to `router.navigateByUrl` only when `allow` lets it through
1014
+ (strings match the path exactly, before any `?` or `#`; a `RegExp` is tested
1015
+ against it; a function decides). `allow` is required, because only the app
1016
+ knows which of its routes a generated UI may open. Whatever `allow` says, a
1017
+ path that does not start with a single `/` is refused: no other origin, no
1018
+ `javascript:` or other scheme, no relative path, no backslash or control
1019
+ character. A refused path, or a navigation the router rejects, is reported
1020
+ with `console.warn` and goes nowhere.
904
1021
 
905
1022
  **A spec cannot render forever.** Two elements naming each other as children,
906
1023
  or one naming itself, would recurse until the tab died. The renderer refuses to
@@ -948,6 +1065,8 @@ Components: `JsonRenderer` (`<json-render>`), `JrChildren`, `JrConfirmDialog`, `
948
1065
 
949
1066
  Injectables/helpers: `injectRenderContext`, `injectElementKey`, `injectRepeatScope`, `injectStateStore`, `injectStateValue`, `injectStateBinding`, `injectBoundProp`, `injectActions`, `injectAction`, `injectValidation`, `injectFieldValidation`, `injectUIStream`, `injectChatUI`, `injectDevtoolsActive`, `injectConfirmContext`, `jsonRenderMessage`, `isActionCancelled`, `checkSpec`.
950
1067
 
1068
+ App-wide defaults: `provideJsonRender`, the `JSON_RENDER_CONFIG` token it fills, and the type `JsonRenderConfig`.
1069
+
951
1070
  Tokens: `JR_CONFIRM_DIALOG` (replace the confirmation dialog), `JR_CONFIRM_LABELS` (its two words), `CONFIRM_CONTEXT`, `RENDER_CONTEXT`, `REPEAT_SCOPE`.
952
1071
 
953
1072
  Spec checking: `checkSpec`, `formatSpecCheckIssues`, and the types
@@ -966,6 +1085,8 @@ Schema alone (`ngx-json-render/schema`): `schema`, `AngularSchema`, `AngularSpec
966
1085
 
967
1086
  MCP Apps (`ngx-json-render/mcp`): `injectJsonRenderApp`, `parseSpecFromToolResult`, `messageText`, and the types `JsonRenderApp`, `JsonRenderAppOptions`. Needs the optional peers `@modelcontextprotocol/ext-apps` and `@modelcontextprotocol/sdk`.
968
1087
 
1088
+ 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.
1089
+
969
1090
  Devtools (`ngx-json-render/devtools`): `JsonRenderDevtools` (`<json-render-devtools>`), and the types `DevtoolsEvent`, `PanelPosition`. Needs the optional peer `@json-render/devtools`.
970
1091
 
971
1092
  Testing (`ngx-json-render/testing`): `renderSpec`, `renderComponent`, `recordedTransport`, `specStream`, `usageLine`.
@@ -977,12 +1098,12 @@ Everything from `@json-render/core` (types, `createStateStore`, `nestedToFlat`,
977
1098
  | Input | Type | Purpose |
978
1099
  | --------------------- | ------------------------------------ | ------------------------------------------------------- |
979
1100
  | `spec` | `Spec \| null` | The UI spec (may be partial while streaming) |
980
- | `registry` | `ComponentRegistry` | Catalog type → Angular component |
1101
+ | `registry` | `ComponentRegistry` | Catalog type → component; required unless provided |
981
1102
  | `loading` | `boolean` | Suppress missing-element warnings while streaming |
982
1103
  | `fallback` | `Type<unknown>` | Component for unknown types |
983
1104
  | `validate` | `'off' \| 'warn' \| 'strict'` | Check the settled spec's structure (default `'off'`) |
984
1105
  | `renderLimits` | `RenderLimits` | Cap elements, depth and repeat expansion (default none) |
985
- | `catalog` | `Catalog` | Also check types and props against the catalog |
1106
+ | `catalog` | `Catalog` | Check types and props; hold back half-streamed props |
986
1107
  | `state` | `StateModel` | Initial state (uncontrolled; defaults to `spec.state`) |
987
1108
  | `store` | `StateStore` | External store (controlled mode) |
988
1109
  | `handlers` | `Record<string, ActionHandler>` | Action handlers |
@@ -994,6 +1115,8 @@ Everything from `@json-render/core` (types, `createStateStore`, `nestedToFlat`,
994
1115
 
995
1116
  Output: `(stateChange)` — batched `{ path, value }[]` in uncontrolled mode.
996
1117
 
1118
+ Every input but `spec`, `loading`, `state` and `store` can come from `provideJsonRender` instead; see [Defaults for the whole app](#defaults-for-the-whole-app).
1119
+
997
1120
  ## License
998
1121
 
999
1122
  Apache-2.0
@@ -0,0 +1,71 @@
1
+ import { inject } from '@angular/core';
2
+ import { Router } from '@angular/router';
3
+
4
+ /**
5
+ * A `navigate` for `<json-render>` that hands a spec's
6
+ * `onSuccess: { navigate }` path to the Angular `Router`, but only a path
7
+ * inside the app that `allow` lets through. Anything else is refused with a
8
+ * warning and goes nowhere.
9
+ *
10
+ * Call it in an injection context — in the function form of
11
+ * `provideJsonRender`, or in a field initializer:
12
+ *
13
+ * ```ts
14
+ * provideJsonRender(() => ({
15
+ * registry,
16
+ * navigate: injectRouterNavigate({ allow: ['/thanks', /^\/orders\/\d+$/] }),
17
+ * }));
18
+ * ```
19
+ *
20
+ * Whatever `allow` says, a path is refused unless it starts with a single
21
+ * `/`: no other origin (`https://…`, `//host`), no scheme (`javascript:…`),
22
+ * no path relative to wherever the user happens to be. A backslash or a
23
+ * control character refuses it too, since browsers read `/\host` as
24
+ * `//host`.
25
+ */
26
+ function injectRouterNavigate(options) {
27
+ const router = inject(Router);
28
+ const allowed = toPredicate(options.allow);
29
+ return (path) => {
30
+ if (!isAppPath(path)) {
31
+ console.warn(`[ngx-json-render] Refused to navigate to ${JSON.stringify(path)}: a spec may only navigate to a path inside the app, starting with a single "/".`);
32
+ return;
33
+ }
34
+ const pathname = path.split(/[?#]/, 1)[0];
35
+ if (!allowed(pathname)) {
36
+ console.warn(`[ngx-json-render] Refused to navigate to "${path}": "${pathname}" is not in the allow list given to injectRouterNavigate.`);
37
+ return;
38
+ }
39
+ router.navigateByUrl(path).catch((error) => {
40
+ console.warn(`[ngx-json-render] Navigation to "${path}" failed.`, error);
41
+ });
42
+ };
43
+ }
44
+ function isAppPath(path) {
45
+ return (typeof path === 'string' &&
46
+ /^\/(?![/\\])/.test(path) &&
47
+ !/[\\\u0000-\u001f\u007f]/.test(path));
48
+ }
49
+ function toPredicate(allow) {
50
+ if (typeof allow === 'function')
51
+ return allow;
52
+ return (path) => allow.some((rule) => {
53
+ if (typeof rule === 'string')
54
+ return rule === path;
55
+ // A global or sticky RegExp keeps `lastIndex` between calls; reset it so
56
+ // one rule answers the same way for the same path every time.
57
+ rule.lastIndex = 0;
58
+ return rule.test(path);
59
+ });
60
+ }
61
+
62
+ /*
63
+ * Public API Surface of ngx-json-render/router
64
+ */
65
+
66
+ /**
67
+ * Generated bundle index. Do not edit.
68
+ */
69
+
70
+ export { injectRouterNavigate };
71
+ //# sourceMappingURL=ngx-json-render-router.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ngx-json-render-router.mjs","sources":["../../../projects/ngx-json-render/router/src/router-navigate.ts","../../../projects/ngx-json-render/router/src/public-api.ts","../../../projects/ngx-json-render/router/src/ngx-json-render-router.ts"],"sourcesContent":["import { inject } from '@angular/core';\nimport { Router } from '@angular/router';\n\n/** Which paths a spec may send the app to. */\nexport interface RouterNavigateOptions {\n /**\n * The paths a spec may navigate to. A string must equal the path exactly; a\n * `RegExp` is tested against it; a function decides on its own. The path is\n * what comes before any `?` or `#`, so a query string or fragment neither\n * helps nor hurts a match, and is passed on to the router as written.\n *\n * Required: a spec is model output, and the app is what knows which of its\n * routes a generated UI has any business opening.\n */\n readonly allow: readonly (string | RegExp)[] | ((path: string) => boolean);\n}\n\n/**\n * A `navigate` for `<json-render>` that hands a spec's\n * `onSuccess: { navigate }` path to the Angular `Router`, but only a path\n * inside the app that `allow` lets through. Anything else is refused with a\n * warning and goes nowhere.\n *\n * Call it in an injection context — in the function form of\n * `provideJsonRender`, or in a field initializer:\n *\n * ```ts\n * provideJsonRender(() => ({\n * registry,\n * navigate: injectRouterNavigate({ allow: ['/thanks', /^\\/orders\\/\\d+$/] }),\n * }));\n * ```\n *\n * Whatever `allow` says, a path is refused unless it starts with a single\n * `/`: no other origin (`https://…`, `//host`), no scheme (`javascript:…`),\n * no path relative to wherever the user happens to be. A backslash or a\n * control character refuses it too, since browsers read `/\\host` as\n * `//host`.\n */\nexport function injectRouterNavigate(\n options: RouterNavigateOptions,\n): (path: string) => void {\n const router = inject(Router);\n const allowed = toPredicate(options.allow);\n return (path) => {\n if (!isAppPath(path)) {\n console.warn(\n `[ngx-json-render] Refused to navigate to ${JSON.stringify(path)}: a spec may only navigate to a path inside the app, starting with a single \"/\".`,\n );\n return;\n }\n const pathname = path.split(/[?#]/, 1)[0];\n if (!allowed(pathname)) {\n console.warn(\n `[ngx-json-render] Refused to navigate to \"${path}\": \"${pathname}\" is not in the allow list given to injectRouterNavigate.`,\n );\n return;\n }\n router.navigateByUrl(path).catch((error: unknown) => {\n console.warn(`[ngx-json-render] Navigation to \"${path}\" failed.`, error);\n });\n };\n}\n\nfunction isAppPath(path: unknown): path is string {\n return (\n typeof path === 'string' &&\n /^\\/(?![/\\\\])/.test(path) &&\n !/[\\\\\\u0000-\\u001f\\u007f]/.test(path)\n );\n}\n\nfunction toPredicate(\n allow: RouterNavigateOptions['allow'],\n): (path: string) => boolean {\n if (typeof allow === 'function') return allow;\n return (path) =>\n allow.some((rule) => {\n if (typeof rule === 'string') return rule === path;\n // A global or sticky RegExp keeps `lastIndex` between calls; reset it so\n // one rule answers the same way for the same path every time.\n rule.lastIndex = 0;\n return rule.test(path);\n });\n}\n","/*\n * Public API Surface of ngx-json-render/router\n */\n\nexport {\n injectRouterNavigate,\n type RouterNavigateOptions,\n} from './router-navigate';\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":";;;AAiBA;;;;;;;;;;;;;;;;;;;;;AAqBG;AACG,SAAU,oBAAoB,CAClC,OAA8B,EAAA;AAE9B,IAAA,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;IAC7B,MAAM,OAAO,GAAG,WAAW,CAAC,OAAO,CAAC,KAAK,CAAC;IAC1C,OAAO,CAAC,IAAI,KAAI;AACd,QAAA,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE;AACpB,YAAA,OAAO,CAAC,IAAI,CACV,CAAA,yCAAA,EAA4C,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA,gFAAA,CAAkF,CACnJ;YACD;QACF;AACA,QAAA,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;AACzC,QAAA,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE;YACtB,OAAO,CAAC,IAAI,CACV,CAAA,0CAAA,EAA6C,IAAI,CAAA,IAAA,EAAO,QAAQ,CAAA,yDAAA,CAA2D,CAC5H;YACD;QACF;QACA,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,KAAc,KAAI;YAClD,OAAO,CAAC,IAAI,CAAC,CAAA,iCAAA,EAAoC,IAAI,CAAA,SAAA,CAAW,EAAE,KAAK,CAAC;AAC1E,QAAA,CAAC,CAAC;AACJ,IAAA,CAAC;AACH;AAEA,SAAS,SAAS,CAAC,IAAa,EAAA;AAC9B,IAAA,QACE,OAAO,IAAI,KAAK,QAAQ;AACxB,QAAA,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC;AACzB,QAAA,CAAC,yBAAyB,CAAC,IAAI,CAAC,IAAI,CAAC;AAEzC;AAEA,SAAS,WAAW,CAClB,KAAqC,EAAA;IAErC,IAAI,OAAO,KAAK,KAAK,UAAU;AAAE,QAAA,OAAO,KAAK;AAC7C,IAAA,OAAO,CAAC,IAAI,KACV,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,KAAI;QAClB,IAAI,OAAO,IAAI,KAAK,QAAQ;YAAE,OAAO,IAAI,KAAK,IAAI;;;AAGlD,QAAA,IAAI,CAAC,SAAS,GAAG,CAAC;AAClB,QAAA,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;AACxB,IAAA,CAAC,CAAC;AACN;;ACpFA;;AAEG;;ACFH;;AAEG;;"}