ngx-json-render 0.9.0 → 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:
@@ -391,15 +423,16 @@ import { JsonRenderer, injectChatUI } from 'ngx-json-render';
391
423
  @for (m of chat.messages(); track m.id) {
392
424
  <p>{{ m.text }}</p>
393
425
  @if (m.spec) {
394
- <json-render [spec]="m.spec" [registry]="registry" />
426
+ <json-render [spec]="m.spec" />
395
427
  }
396
428
  }
397
429
  <button (click)="chat.send('show me revenue for the quarter')">Ask</button>
398
430
  `,
399
431
  })
400
432
  export class ChatPage {
433
+ // The registry comes from provideJsonRender({ registry }) in the app's
434
+ // providers: see "Defaults for the whole app".
401
435
  readonly chat = injectChatUI({ api: '/api/chat' });
402
- readonly registry = registry;
403
436
  }
404
437
  ```
405
438
 
@@ -965,7 +998,26 @@ rather than the one holding session, entitlement or billing state.
965
998
  **A spec chooses the navigation target.** `onSuccess: { navigate }` passes its
966
999
  string to the `navigate` callback you provide, verbatim. Treat it as untrusted:
967
1000
  match it against known routes, and never hand it to `window.location` or
968
- `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.
969
1021
 
970
1022
  **A spec cannot render forever.** Two elements naming each other as children,
971
1023
  or one naming itself, would recurse until the tab died. The renderer refuses to
@@ -1013,6 +1065,8 @@ Components: `JsonRenderer` (`<json-render>`), `JrChildren`, `JrConfirmDialog`, `
1013
1065
 
1014
1066
  Injectables/helpers: `injectRenderContext`, `injectElementKey`, `injectRepeatScope`, `injectStateStore`, `injectStateValue`, `injectStateBinding`, `injectBoundProp`, `injectActions`, `injectAction`, `injectValidation`, `injectFieldValidation`, `injectUIStream`, `injectChatUI`, `injectDevtoolsActive`, `injectConfirmContext`, `jsonRenderMessage`, `isActionCancelled`, `checkSpec`.
1015
1067
 
1068
+ App-wide defaults: `provideJsonRender`, the `JSON_RENDER_CONFIG` token it fills, and the type `JsonRenderConfig`.
1069
+
1016
1070
  Tokens: `JR_CONFIRM_DIALOG` (replace the confirmation dialog), `JR_CONFIRM_LABELS` (its two words), `CONFIRM_CONTEXT`, `RENDER_CONTEXT`, `REPEAT_SCOPE`.
1017
1071
 
1018
1072
  Spec checking: `checkSpec`, `formatSpecCheckIssues`, and the types
@@ -1031,6 +1085,8 @@ Schema alone (`ngx-json-render/schema`): `schema`, `AngularSchema`, `AngularSpec
1031
1085
 
1032
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`.
1033
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
+
1034
1090
  Devtools (`ngx-json-render/devtools`): `JsonRenderDevtools` (`<json-render-devtools>`), and the types `DevtoolsEvent`, `PanelPosition`. Needs the optional peer `@json-render/devtools`.
1035
1091
 
1036
1092
  Testing (`ngx-json-render/testing`): `renderSpec`, `renderComponent`, `recordedTransport`, `specStream`, `usageLine`.
@@ -1042,7 +1098,7 @@ Everything from `@json-render/core` (types, `createStateStore`, `nestedToFlat`,
1042
1098
  | Input | Type | Purpose |
1043
1099
  | --------------------- | ------------------------------------ | ------------------------------------------------------- |
1044
1100
  | `spec` | `Spec \| null` | The UI spec (may be partial while streaming) |
1045
- | `registry` | `ComponentRegistry` | Catalog type → Angular component |
1101
+ | `registry` | `ComponentRegistry` | Catalog type → component; required unless provided |
1046
1102
  | `loading` | `boolean` | Suppress missing-element warnings while streaming |
1047
1103
  | `fallback` | `Type<unknown>` | Component for unknown types |
1048
1104
  | `validate` | `'off' \| 'warn' \| 'strict'` | Check the settled spec's structure (default `'off'`) |
@@ -1059,6 +1115,8 @@ Everything from `@json-render/core` (types, `createStateStore`, `nestedToFlat`,
1059
1115
 
1060
1116
  Output: `(stateChange)` — batched `{ path, value }[]` in uncontrolled mode.
1061
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
+
1062
1120
  ## License
1063
1121
 
1064
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;;"}
@@ -2377,6 +2377,92 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2377
2377
  `, styles: [":host{display:contents}\n"] }]
2378
2378
  }], ctorParameters: () => [], propDecorators: { elementKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "elementKey", required: true }] }] } });
2379
2379
 
2380
+ /**
2381
+ * The defaults the nearest {@link provideJsonRender} supplies, already merged
2382
+ * with any provider above it.
2383
+ */
2384
+ const JSON_RENDER_CONFIG = new InjectionToken('JSON_RENDER_CONFIG');
2385
+ /**
2386
+ * Set renderer defaults once instead of on every `<json-render>`:
2387
+ *
2388
+ * ```ts
2389
+ * bootstrapApplication(App, { providers: [provideJsonRender({ registry })] });
2390
+ * ```
2391
+ *
2392
+ * ```html
2393
+ * <json-render [spec]="m.spec" />
2394
+ * ```
2395
+ *
2396
+ * Pass a function to build the defaults with `inject()`, for handlers that
2397
+ * call your services:
2398
+ *
2399
+ * ```ts
2400
+ * provideJsonRender(() => {
2401
+ * const orders = inject(OrderService);
2402
+ * return { registry, handlers: { cancelOrder: (p) => orders.cancel(p['id']) } };
2403
+ * });
2404
+ * ```
2405
+ *
2406
+ * Works in application, route and component `providers`. A provider below
2407
+ * another extends it with the same rules an element input does: its own
2408
+ * values replace, its named vocabularies merge.
2409
+ */
2410
+ function provideJsonRender(config) {
2411
+ return [
2412
+ {
2413
+ provide: JSON_RENDER_CONFIG,
2414
+ useFactory: () => {
2415
+ const outer = inject(JSON_RENDER_CONFIG, {
2416
+ optional: true,
2417
+ skipSelf: true,
2418
+ });
2419
+ const own = typeof config === 'function' ? config() : config;
2420
+ return outer ? mergeJsonRenderConfig(outer, own) : own;
2421
+ },
2422
+ },
2423
+ ];
2424
+ }
2425
+ /** `inner` over `outer`, by the rules {@link JsonRenderConfig} describes. */
2426
+ function mergeJsonRenderConfig(outer, inner) {
2427
+ const merged = { ...outer };
2428
+ for (const [key, value] of Object.entries(inner)) {
2429
+ if (value !== undefined && value !== null) {
2430
+ merged[key] = value;
2431
+ }
2432
+ }
2433
+ merged.handlers = mergeByName(outer.handlers, inner.handlers);
2434
+ merged.functions = mergeByName(outer.functions, inner.functions);
2435
+ merged.validationFunctions = mergeByName(outer.validationFunctions, inner.validationFunctions);
2436
+ merged.directives = mergeDirectives(outer.directives, inner.directives);
2437
+ return merged;
2438
+ }
2439
+ /**
2440
+ * Two name → value maps as one, `inner` winning a name both define. Returns
2441
+ * whichever is given when only one is, so an unmerged map keeps its identity.
2442
+ *
2443
+ * @internal
2444
+ */
2445
+ function mergeByName(outer, inner) {
2446
+ if (!outer)
2447
+ return inner ?? undefined;
2448
+ if (!inner)
2449
+ return outer;
2450
+ return { ...outer, ...inner };
2451
+ }
2452
+ /**
2453
+ * Two directive lists as one, `inner` winning a name both define — the
2454
+ * directive registry keeps the last definition of a name.
2455
+ *
2456
+ * @internal
2457
+ */
2458
+ function mergeDirectives(outer, inner) {
2459
+ if (!outer)
2460
+ return inner ?? undefined;
2461
+ if (!inner)
2462
+ return outer;
2463
+ return [...outer, ...inner];
2464
+ }
2465
+
2380
2466
  const CLEAN = {
2381
2467
  spec: null,
2382
2468
  issues: [],
@@ -2526,8 +2612,11 @@ function reportSpecCheck(check, mode) {
2526
2612
  class JsonRenderer {
2527
2613
  /** The UI spec to render (may be partial while streaming). */
2528
2614
  spec = input.required(...(ngDevMode ? [{ debugName: "spec" }] : /* istanbul ignore next */ []));
2529
- /** Component registry mapping catalog type names to Angular components. */
2530
- registry = input.required(...(ngDevMode ? [{ debugName: "registry" }] : /* istanbul ignore next */ []));
2615
+ /**
2616
+ * Component registry mapping catalog type names to Angular components.
2617
+ * Required unless a {@link provideJsonRender} above supplies one.
2618
+ */
2619
+ registry = input(undefined, ...(ngDevMode ? [{ debugName: "registry" }] : /* istanbul ignore next */ []));
2531
2620
  /** Whether the spec is currently loading/streaming. */
2532
2621
  loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
2533
2622
  /** Fallback component for unknown types. */
@@ -2544,7 +2633,7 @@ class JsonRenderer {
2544
2633
  * The check is skipped while `loading` is true: a spec that is still
2545
2634
  * arriving is expected to reference children that have not streamed in yet.
2546
2635
  */
2547
- validate = input('off', ...(ngDevMode ? [{ debugName: "validate" }] : /* istanbul ignore next */ []));
2636
+ validate = input(undefined, ...(ngDevMode ? [{ debugName: "validate" }] : /* istanbul ignore next */ []));
2548
2637
  /**
2549
2638
  * Caps on what the spec may cost the browser: `maxElements`, `maxDepth` and
2550
2639
  * `maxRepeatItems`. Unset by default, and each field is independently
@@ -2601,34 +2690,52 @@ class JsonRenderer {
2601
2690
  stateStore;
2602
2691
  /** The action dispatcher of this renderer. */
2603
2692
  actions;
2693
+ /**
2694
+ * The nearest {@link provideJsonRender}'s defaults. Every option input
2695
+ * falls through to these when left `null` or `undefined`; see
2696
+ * {@link JsonRenderConfig} for the merge rules.
2697
+ */
2698
+ config = inject(JSON_RENDER_CONFIG, { optional: true }) ?? {};
2699
+ mode = computed(() => this.validate() ?? this.config.validate ?? 'off', ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
2700
+ limits = computed(() => this.renderLimits() ?? this.config.renderLimits ?? null, ...(ngDevMode ? [{ debugName: "limits" }] : /* istanbul ignore next */ []));
2701
+ specCatalog = computed(() => this.catalog() ?? this.config.catalog ?? null, ...(ngDevMode ? [{ debugName: "specCatalog" }] : /* istanbul ignore next */ []));
2604
2702
  /**
2605
2703
  * The spec as checked, and what the check found. While `loading`, the mode
2606
2704
  * is forced off: the spec is still arriving, and the missing children it
2607
2705
  * refers to are the normal state of a stream, not a defect.
2608
2706
  */
2609
- checked = computed(() => checkSpec(this.spec(), this.loading() ? 'off' : this.validate(), {
2610
- limits: this.renderLimits(),
2611
- catalog: this.catalog(),
2707
+ checked = computed(() => checkSpec(this.spec(), this.loading() ? 'off' : this.mode(), {
2708
+ limits: this.limits(),
2709
+ catalog: this.specCatalog(),
2612
2710
  }), ...(ngDevMode ? [{ debugName: "checked" }] : /* istanbul ignore next */ []));
2613
2711
  constructor() {
2614
2712
  const root = inject(JsonRenderRootContext);
2615
2713
  // The fixed spec, not the input: a `visible` the check moved out of
2616
2714
  // `props` has to be the one the tree renders, or the fix is cosmetic.
2617
2715
  root.spec = computed(() => this.checked().spec, ...(ngDevMode ? [{ debugName: "spec" }] : /* istanbul ignore next */ []));
2618
- root.registry = this.registry;
2716
+ const config = this.config;
2717
+ // Read only once an element needs a component, like a required input:
2718
+ // a renderer with no spec yet has no use for one.
2719
+ root.registry = computed(() => {
2720
+ const registry = this.registry() ?? config.registry;
2721
+ if (!registry) {
2722
+ throw new Error('[ngx-json-render] <json-render> has no registry. Bind [registry], or supply one for the whole app with provideJsonRender({ registry }).');
2723
+ }
2724
+ return registry;
2725
+ }, ...(ngDevMode ? [{ debugName: "registry" }] : /* istanbul ignore next */ []));
2619
2726
  root.loading = this.loading;
2620
- root.fallback = this.fallback;
2621
- root.limits = this.renderLimits;
2622
- root.catalog = this.catalog;
2727
+ root.fallback = computed(() => this.fallback() ?? config.fallback ?? null, ...(ngDevMode ? [{ debugName: "fallback" }] : /* istanbul ignore next */ []));
2728
+ root.limits = this.limits;
2729
+ root.catalog = this.specCatalog;
2623
2730
  root.store = this.store;
2624
2731
  root.initialState = computed(() => this.state() ?? this.spec()?.state ?? {}, ...(ngDevMode ? [{ debugName: "initialState" }] : /* istanbul ignore next */ []));
2625
- root.handlers = this.handlers;
2626
- root.onAction = this.onAction;
2627
- root.navigate = this.navigate;
2628
- root.validationFunctions = this.validationFunctions;
2629
- root.functions = this.functions;
2732
+ root.handlers = computed(() => mergeByName(config.handlers, this.handlers()), ...(ngDevMode ? [{ debugName: "handlers" }] : /* istanbul ignore next */ []));
2733
+ root.onAction = computed(() => this.onAction() ?? config.onAction ?? null, ...(ngDevMode ? [{ debugName: "onAction" }] : /* istanbul ignore next */ []));
2734
+ root.navigate = computed(() => this.navigate() ?? config.navigate ?? null, ...(ngDevMode ? [{ debugName: "navigate" }] : /* istanbul ignore next */ []));
2735
+ root.validationFunctions = computed(() => mergeByName(config.validationFunctions, this.validationFunctions()), ...(ngDevMode ? [{ debugName: "validationFunctions" }] : /* istanbul ignore next */ []));
2736
+ root.functions = computed(() => mergeByName(config.functions, this.functions()), ...(ngDevMode ? [{ debugName: "functions" }] : /* istanbul ignore next */ []));
2630
2737
  root.directiveRegistry = computed(() => {
2631
- const definitions = this.directives();
2738
+ const definitions = mergeDirectives(config.directives, this.directives());
2632
2739
  return definitions ? createDirectiveRegistry(definitions) : undefined;
2633
2740
  }, ...(ngDevMode ? [{ debugName: "directiveRegistry" }] : /* istanbul ignore next */ []));
2634
2741
  root.emitStateChange = (changes) => this.stateChange.emit(changes);
@@ -2644,7 +2751,25 @@ class JsonRenderer {
2644
2751
  effect(() => {
2645
2752
  if (this.loading())
2646
2753
  return;
2647
- reportSpecCheck(this.checked(), this.validate());
2754
+ reportSpecCheck(this.checked(), this.mode());
2755
+ });
2756
+ // With validation off, a `root` that names no element renders nothing and
2757
+ // nothing says why — the first mistake a hand-written spec tends to have.
2758
+ // Skipped while loading (a stream sends `/root` before its element) and
2759
+ // under `warn`/`strict`, where the check already reports `root_not_found`.
2760
+ // Once per root key, not once per change detection run.
2761
+ const warnedRoots = new Set();
2762
+ effect(() => {
2763
+ if (this.loading() || this.mode() !== 'off')
2764
+ return;
2765
+ const spec = this.checked().spec;
2766
+ if (!spec?.root)
2767
+ return;
2768
+ const root = spec.root;
2769
+ if (spec.elements?.[root] || warnedRoots.has(root))
2770
+ return;
2771
+ warnedRoots.add(root);
2772
+ console.warn(`[ngx-json-render] root "${root}" is not in elements; nothing is rendered.`);
2648
2773
  });
2649
2774
  }
2650
2775
  rootKey = computed(() => {
@@ -2657,7 +2782,7 @@ class JsonRenderer {
2657
2782
  // A spec whose errors survived the fixes does not render under `strict`.
2658
2783
  // Reporting it and drawing half of it anyway is the behaviour the mode
2659
2784
  // exists to refuse.
2660
- if (this.validate() === 'strict' && checked.hasErrors)
2785
+ if (this.mode() === 'strict' && checked.hasErrors)
2661
2786
  return null;
2662
2787
  const spec = checked.spec;
2663
2788
  return spec?.root && spec.elements?.[spec.root] ? spec.root : null;
@@ -2696,7 +2821,7 @@ class JsonRenderer {
2696
2821
  });
2697
2822
  }, ...(ngDevMode ? [{ debugName: "confirmInjector" }] : /* istanbul ignore next */ []));
2698
2823
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderer, deps: [], target: i0.ɵɵFactoryTarget.Component });
2699
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: JsonRenderer, isStandalone: true, selector: "json-render", inputs: { spec: { classPropertyName: "spec", publicName: "spec", isSignal: true, isRequired: true, transformFunction: null }, registry: { classPropertyName: "registry", publicName: "registry", isSignal: true, isRequired: true, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, fallback: { classPropertyName: "fallback", publicName: "fallback", isSignal: true, isRequired: false, transformFunction: null }, validate: { classPropertyName: "validate", publicName: "validate", isSignal: true, isRequired: false, transformFunction: null }, renderLimits: { classPropertyName: "renderLimits", publicName: "renderLimits", isSignal: true, isRequired: false, transformFunction: null }, catalog: { classPropertyName: "catalog", publicName: "catalog", isSignal: true, isRequired: false, transformFunction: null }, state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: false, transformFunction: null }, store: { classPropertyName: "store", publicName: "store", isSignal: true, isRequired: false, transformFunction: null }, handlers: { classPropertyName: "handlers", publicName: "handlers", isSignal: true, isRequired: false, transformFunction: null }, onAction: { classPropertyName: "onAction", publicName: "onAction", isSignal: true, isRequired: false, transformFunction: null }, navigate: { classPropertyName: "navigate", publicName: "navigate", isSignal: true, isRequired: false, transformFunction: null }, validationFunctions: { classPropertyName: "validationFunctions", publicName: "validationFunctions", isSignal: true, isRequired: false, transformFunction: null }, functions: { classPropertyName: "functions", publicName: "functions", isSignal: true, isRequired: false, transformFunction: null }, directives: { classPropertyName: "directives", publicName: "directives", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { stateChange: "stateChange" }, providers: [
2824
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: JsonRenderer, isStandalone: true, selector: "json-render", inputs: { spec: { classPropertyName: "spec", publicName: "spec", isSignal: true, isRequired: true, transformFunction: null }, registry: { classPropertyName: "registry", publicName: "registry", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, fallback: { classPropertyName: "fallback", publicName: "fallback", isSignal: true, isRequired: false, transformFunction: null }, validate: { classPropertyName: "validate", publicName: "validate", isSignal: true, isRequired: false, transformFunction: null }, renderLimits: { classPropertyName: "renderLimits", publicName: "renderLimits", isSignal: true, isRequired: false, transformFunction: null }, catalog: { classPropertyName: "catalog", publicName: "catalog", isSignal: true, isRequired: false, transformFunction: null }, state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: false, transformFunction: null }, store: { classPropertyName: "store", publicName: "store", isSignal: true, isRequired: false, transformFunction: null }, handlers: { classPropertyName: "handlers", publicName: "handlers", isSignal: true, isRequired: false, transformFunction: null }, onAction: { classPropertyName: "onAction", publicName: "onAction", isSignal: true, isRequired: false, transformFunction: null }, navigate: { classPropertyName: "navigate", publicName: "navigate", isSignal: true, isRequired: false, transformFunction: null }, validationFunctions: { classPropertyName: "validationFunctions", publicName: "validationFunctions", isSignal: true, isRequired: false, transformFunction: null }, functions: { classPropertyName: "functions", publicName: "functions", isSignal: true, isRequired: false, transformFunction: null }, directives: { classPropertyName: "directives", publicName: "directives", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { stateChange: "stateChange" }, providers: [
2700
2825
  JsonRenderRootContext,
2701
2826
  JsonRenderStateService,
2702
2827
  JsonRenderValidationService,
@@ -2745,7 +2870,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2745
2870
  }
2746
2871
  }
2747
2872
  `, styles: [":host{display:contents}\n"] }]
2748
- }], ctorParameters: () => [], propDecorators: { spec: [{ type: i0.Input, args: [{ isSignal: true, alias: "spec", required: true }] }], registry: [{ type: i0.Input, args: [{ isSignal: true, alias: "registry", required: true }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], fallback: [{ type: i0.Input, args: [{ isSignal: true, alias: "fallback", required: false }] }], validate: [{ type: i0.Input, args: [{ isSignal: true, alias: "validate", required: false }] }], renderLimits: [{ type: i0.Input, args: [{ isSignal: true, alias: "renderLimits", required: false }] }], catalog: [{ type: i0.Input, args: [{ isSignal: true, alias: "catalog", required: false }] }], state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: false }] }], store: [{ type: i0.Input, args: [{ isSignal: true, alias: "store", required: false }] }], handlers: [{ type: i0.Input, args: [{ isSignal: true, alias: "handlers", required: false }] }], onAction: [{ type: i0.Input, args: [{ isSignal: true, alias: "onAction", required: false }] }], navigate: [{ type: i0.Input, args: [{ isSignal: true, alias: "navigate", required: false }] }], validationFunctions: [{ type: i0.Input, args: [{ isSignal: true, alias: "validationFunctions", required: false }] }], functions: [{ type: i0.Input, args: [{ isSignal: true, alias: "functions", required: false }] }], directives: [{ type: i0.Input, args: [{ isSignal: true, alias: "directives", required: false }] }], stateChange: [{ type: i0.Output, args: ["stateChange"] }] } });
2873
+ }], ctorParameters: () => [], propDecorators: { spec: [{ type: i0.Input, args: [{ isSignal: true, alias: "spec", required: true }] }], registry: [{ type: i0.Input, args: [{ isSignal: true, alias: "registry", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], fallback: [{ type: i0.Input, args: [{ isSignal: true, alias: "fallback", required: false }] }], validate: [{ type: i0.Input, args: [{ isSignal: true, alias: "validate", required: false }] }], renderLimits: [{ type: i0.Input, args: [{ isSignal: true, alias: "renderLimits", required: false }] }], catalog: [{ type: i0.Input, args: [{ isSignal: true, alias: "catalog", required: false }] }], state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: false }] }], store: [{ type: i0.Input, args: [{ isSignal: true, alias: "store", required: false }] }], handlers: [{ type: i0.Input, args: [{ isSignal: true, alias: "handlers", required: false }] }], onAction: [{ type: i0.Input, args: [{ isSignal: true, alias: "onAction", required: false }] }], navigate: [{ type: i0.Input, args: [{ isSignal: true, alias: "navigate", required: false }] }], validationFunctions: [{ type: i0.Input, args: [{ isSignal: true, alias: "validationFunctions", required: false }] }], functions: [{ type: i0.Input, args: [{ isSignal: true, alias: "functions", required: false }] }], directives: [{ type: i0.Input, args: [{ isSignal: true, alias: "directives", required: false }] }], stateChange: [{ type: i0.Output, args: ["stateChange"] }] } });
2749
2874
 
2750
2875
  /**
2751
2876
  * Provides the repeat scope (item, index, absolute base path) to the elements
@@ -3886,5 +4011,5 @@ function injectChatUI(options) {
3886
4011
  * Generated bundle index. Do not edit.
3887
4012
  */
3888
4013
 
3889
- export { CONFIRM_CONTEXT, ELEMENT_KEY, JR_CONFIRM_DIALOG, JR_CONFIRM_LABELS, JrChildren, JrConfirmDialog, JrElement, JrRepeatScope, JsonRenderActionsService, JsonRenderRootContext, JsonRenderStateService, JsonRenderValidationService, JsonRenderer, RENDER_CONTEXT, REPEAT_SCOPE, applyPatch, buildSpecFromParts, checkSpec, createStoreSetState, defineRegistry, flatToTree, formatSpecCheckIssues, getTextFromParts, injectAction, injectActions, injectBoundProp, injectChatUI, injectConfirmContext, injectDevtoolsActive, injectElementKey, injectFieldValidation, injectRenderContext, injectRepeatScope, injectStateBinding, injectStateStore, injectStateValue, injectUIStream, injectValidation, isActionCancelled, jsonRenderMessage, ɵregisterStreamObserver };
4014
+ export { CONFIRM_CONTEXT, ELEMENT_KEY, JR_CONFIRM_DIALOG, JR_CONFIRM_LABELS, JSON_RENDER_CONFIG, JrChildren, JrConfirmDialog, JrElement, JrRepeatScope, JsonRenderActionsService, JsonRenderRootContext, JsonRenderStateService, JsonRenderValidationService, JsonRenderer, RENDER_CONTEXT, REPEAT_SCOPE, applyPatch, buildSpecFromParts, checkSpec, createStoreSetState, defineRegistry, flatToTree, formatSpecCheckIssues, getTextFromParts, injectAction, injectActions, injectBoundProp, injectChatUI, injectConfirmContext, injectDevtoolsActive, injectElementKey, injectFieldValidation, injectRenderContext, injectRepeatScope, injectStateBinding, injectStateStore, injectStateValue, injectUIStream, injectValidation, isActionCancelled, jsonRenderMessage, provideJsonRender, ɵregisterStreamObserver };
3890
4015
  //# sourceMappingURL=ngx-json-render.mjs.map