ngx-json-render 0.2.1 → 0.3.0

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  Angular renderer for [json-render](https://github.com/vercel-labs/json-render) — give an LLM a catalog of your components, stream back a JSON spec, and render it as real Angular components. No `innerHTML`, no `eval`, no framework lock-in on the wire format.
6
6
 
7
- Built on `@json-render/core` (the same spec format, expressions, state store, actions, and streaming compiler used by the React, Vue, Solid, and Svelte renderers) and idiomatic modern Angular: standalone components, signals, zoneless-friendly, `OnPush` everywhere.
7
+ Built on `@json-render/core` (the same spec format, expressions, state store, actions, and streaming compiler used by the React, Vue, Solid, and Svelte renderers) and idiomatic modern Angular: standalone components, signals, `OnPush` everywhere, and zoneless — no `NgZone`, no Zone.js.
8
8
 
9
9
  This is an Angular **adapter over the official core**, not a second implementation of it — `@json-render/core` is a peer dependency, and the spec your model emits is the same one the React, Vue, Solid and Svelte renderers consume. A catalog and a spec written here move to another framework unchanged.
10
10
 
@@ -18,7 +18,62 @@ This is an Angular **adapter over the official core**, not a second implementati
18
18
  npm install ngx-json-render @json-render/core zod
19
19
  ```
20
20
 
21
- Requires Angular ≥ 20.
21
+ Requires Angular ≥ 20. CI proves both ends of that range on every push: the
22
+ library builds and its suite passes on Angular 20 (the floor) and on Angular
23
+ 22 (the current release), as well as on the 21 the workspace itself pins.
24
+
25
+ **Zoneless, and checked as such.** Zone.js appears in no manifest here, `NgZone`
26
+ in no source file, and neither in the published bundles; every suite runs under
27
+ `provideZonelessChangeDetection()`. CI asserts all four on each commit
28
+ (`npm run check:zoneless`), so this is a tested property rather than a
29
+ statement of intent — which matters because zoneless is the default for new
30
+ applications from Angular 21 on.
31
+
32
+ ## The shortest path: a ready-made catalog
33
+
34
+ Writing a catalog is the honest first step, but you do not have to take it to
35
+ see a spec render. [`ngx-json-render-material`](https://www.npmjs.com/package/ngx-json-render-material)
36
+ ships 28 Angular Material components already registered, so a generated spec
37
+ renders with nothing else wired up:
38
+
39
+ ```bash
40
+ npm install ngx-json-render-material @angular/material
41
+ ```
42
+
43
+ Those are real Material components, so the app needs a Material theme and the
44
+ icon font as usual.
45
+
46
+ ```ts
47
+ import { Component, signal } from '@angular/core';
48
+ import { JsonRenderer, type Spec } from 'ngx-json-render';
49
+ import { materialRegistry } from 'ngx-json-render-material';
50
+
51
+ @Component({
52
+ selector: 'app-root',
53
+ imports: [JsonRenderer],
54
+ template: `<json-render [spec]="spec()" [registry]="registry" />`,
55
+ })
56
+ export class App {
57
+ readonly registry = materialRegistry;
58
+ readonly spec = signal<Spec>({
59
+ root: 'card',
60
+ state: { name: '' },
61
+ elements: {
62
+ card: { type: 'Card', props: { title: 'Profile' }, children: ['name', 'hi'] },
63
+ name: { type: 'Input', props: { label: 'Name', value: { $bindState: '/name' } } },
64
+ hi: {
65
+ type: 'Text',
66
+ props: { content: { $template: 'Hello, ${/name}!' } },
67
+ visible: { $state: '/name', neq: '' },
68
+ },
69
+ },
70
+ });
71
+ }
72
+ ```
73
+
74
+ `materialCatalog.prompt()` is the system prompt that teaches a model that
75
+ vocabulary. Everything below is the other path — your own components, which is
76
+ what the catalog API is for.
22
77
 
23
78
  ## Quick start
24
79
 
@@ -83,7 +138,13 @@ export class ButtonComponent {
83
138
 
84
139
  ```ts
85
140
  import { Component, signal } from '@angular/core';
86
- import { JsonRenderer, type Spec, defineRegistry } from 'ngx-json-render';
141
+ import {
142
+ type ActionHandler,
143
+ JsonRenderer,
144
+ type Spec,
145
+ type StateChange,
146
+ defineRegistry,
147
+ } from 'ngx-json-render';
87
148
  import { catalog } from './catalog';
88
149
 
89
150
  const { registry } = defineRegistry(catalog, {
@@ -119,8 +180,10 @@ export class Page {
119
180
  },
120
181
  },
121
182
  });
122
- handlers = { refresh: async () => { /* ... */ } };
123
- onStateChange(changes: unknown) { console.log(changes); }
183
+ readonly handlers: Record<string, ActionHandler> = {
184
+ refresh: async () => { /* ... */ },
185
+ };
186
+ onStateChange(changes: StateChange[]) { console.log(changes); }
124
187
  }
125
188
  ```
126
189
 
@@ -178,6 +241,61 @@ app.post('/api/generate', async (req, res) => {
178
241
 
179
242
  The patches apply to the `spec` signal as each line arrives, so the UI assembles on screen while the model is still generating — exactly what the [demo's Streaming tab](https://shteynu.github.io/ngx-json-render/) replays. Prefer structured output? `catalog.jsonSchema()` exports a JSON Schema for `streamObject`/tool calls, and `catalog.validate(spec)` checks a finished spec against the catalog.
180
243
 
244
+ ### Chat + GenUI
245
+
246
+ When the model answers in prose _and_ renders a UI in the same turn, reach for
247
+ `injectChatUI` instead. Its endpoint takes `{ messages }` and streams prose
248
+ mixed with ` ```spec ` fenced JSONL. Each assistant message carries `text`
249
+ and/or its own `spec`, so earlier turns keep the UI they generated:
250
+
251
+ ```ts
252
+ import { Component } from '@angular/core';
253
+ import { JsonRenderer, injectChatUI } from 'ngx-json-render';
254
+
255
+ @Component({
256
+ imports: [JsonRenderer],
257
+ template: `
258
+ @for (m of chat.messages(); track m.id) {
259
+ <p>{{ m.text }}</p>
260
+ @if (m.spec) {
261
+ <json-render [spec]="m.spec" [registry]="registry" />
262
+ }
263
+ }
264
+ <button (click)="chat.send('show me revenue for the quarter')">Ask</button>
265
+ `,
266
+ })
267
+ export class ChatPage {
268
+ readonly chat = injectChatUI({ api: '/api/chat' });
269
+ readonly registry = registry;
270
+ }
271
+ ```
272
+
273
+ ### Reading an AI SDK message
274
+
275
+ If the UI arrives as AI SDK data parts rather than through these hooks,
276
+ `buildSpecFromParts` / `getTextFromParts` / `jsonRenderMessage` read a
277
+ `UIMessage.parts` array directly — pass it as it is, no cast:
278
+
279
+ ```ts
280
+ readonly msg = jsonRenderMessage(() => this.message().parts);
281
+ // template: {{ msg.text() }} @if (msg.hasSpec()) { <json-render [spec]="msg.spec()" ... /> }
282
+ ```
283
+
284
+ Two things about the parts themselves, both the SDK's semantics rather than
285
+ this package's, and both silent when you get them wrong:
286
+
287
+ - **Do not give patch parts an `id`.** A data part written with one is
288
+ _replaced_ by the next part carrying the same id, so a spec streamed as
289
+ patches under one id arrives as its last patch alone. An id is for a part
290
+ that is a snapshot of itself — a `flat` or `nested` whole-spec part.
291
+ - **A transient part never reaches `message.parts`.** It goes to `onData` and
292
+ nowhere else, so a spec written transiently cannot be rebuilt from the
293
+ message.
294
+
295
+ `projects/ngx-json-render/src/lib/ai-sdk-parts.spec.ts` runs the real SDK —
296
+ writes the chunks a server route would, reads the message a client would —
297
+ and asserts both of these, so this section cannot quietly go stale.
298
+
181
299
  ### Supplying the transport
182
300
 
183
301
  By default the request goes through the global `fetch`. Pass your own to add
@@ -218,11 +336,107 @@ The second argument also carries `context`, forwarded to the endpoint as-is:
218
336
 
219
337
  Also available:
220
338
 
221
- - `injectChatUI({ api, fetch? })` — chat + GenUI: the endpoint takes `{ messages }` and streams prose mixed with ` ```spec ` fenced JSONL; each assistant message carries `text` and/or a `spec`.
222
339
  - `applyPatch(spec, patch)` — immutably apply one RFC 6902 patch to a spec, sharing every subtree the patch did not touch. Both hooks and `buildSpecFromParts` apply through it, so the same stream produces the same spec whichever one you reach for. One deliberate deviation from the RFC: a failing `test` op is a no-op rather than an abort, because these patches come off a model's output and dropping a bad line beats killing the generation.
223
340
  - `buildSpecFromParts` / `getTextFromParts` / `jsonRenderMessage` — derive specs from AI SDK `message.parts`.
224
341
  - `catalog.prompt()` / `buildUserPrompt` (from `@json-render/core`) — generate the system/user prompts for your catalog.
225
342
 
343
+ ### Checking what the model produced
344
+
345
+ A generation can end malformed — a child that never arrived, a `visible` the
346
+ model wrote inside `props` where nothing reads it. Opt into a structural check
347
+ with `validate`, on the renderer or on either hook:
348
+
349
+ ```html
350
+ <json-render [spec]="ui.spec()" [registry]="registry" [loading]="ui.isStreaming()" validate="warn" />
351
+ ```
352
+
353
+ ```ts
354
+ readonly ui = injectUIStream({ api: '/api/generate', validate: 'strict' });
355
+ // ui.issues() — what was wrong with the finished spec
356
+ ```
357
+
358
+ - `'off'` (the default) — render whatever arrives, as before.
359
+ - `'warn'` — report what is wrong and render anyway.
360
+ - `'strict'` — a spec with errors does not render, and a generation that ends
361
+ with one fails instead of completing, so it never reaches the `onComplete`
362
+ where apps persist it.
363
+
364
+ Both modes first apply the lossless fixes `autoFixSpec` provides: `visible`,
365
+ `on` and `repeat` misplaced inside `props` move back onto the element, where
366
+ they take effect instead of being ignored. Content is never pruned — the lossy
367
+ fixes are left out on purpose, because re-prompting beats a renderer that
368
+ silently deletes elements.
369
+
370
+ The check waits for the spec to settle. While `loading` is true a missing child
371
+ is a patch that has not arrived yet, not a defect, so nothing is reported and
372
+ `strict` keeps rendering; the hooks check once, when the generation completes.
373
+
374
+ ## Testing
375
+
376
+ `ngx-json-render/testing` is a separate entry point, so nothing in it can reach
377
+ an application bundle by accident.
378
+
379
+ ### Rendering a spec
380
+
381
+ `renderSpec` mounts a spec against a registry and hands back the few things a
382
+ test does to one — no host component, no TestBed module, no settling by hand:
383
+
384
+ ```ts
385
+ import { renderSpec } from 'ngx-json-render/testing';
386
+
387
+ it('dispatches the action its spec asked for', async () => {
388
+ const ui = await renderSpec(
389
+ {
390
+ root: 'save',
391
+ elements: {
392
+ save: {
393
+ type: 'Button',
394
+ props: { label: 'Save' },
395
+ on: { press: { action: 'save' } },
396
+ },
397
+ },
398
+ },
399
+ { registry: { Button: MyButton } },
400
+ );
401
+
402
+ expect(ui.text('button')).toBe('Save');
403
+ await ui.click('button');
404
+ expect(ui.dispatched).toEqual([{ name: 'save', params: {} }]);
405
+ });
406
+ ```
407
+
408
+ `dispatched` records every action the spec fired, handled or not. The rest of
409
+ the harness: `text` / `texts` / `find` / `findAll` for the DOM, `click` /
410
+ `fill` for input, `read` / `write` / `state` / `changes` for state, `setSpec` /
411
+ `setLoading` / `settle` for later frames, and `fixture` / `element` /
412
+ `renderer` / `store` / `validation` for everything the harness does not cover.
413
+ Options mirror the renderer's inputs, plus `providers` for anything the
414
+ components under test inject.
415
+
416
+ ### Replaying a generation
417
+
418
+ `recordedTransport` is a `fetch` that answers from a recording, so a test runs
419
+ the real client — request body, streamed lines, usage metadata, abort on
420
+ supersede — with no server and no API key:
421
+
422
+ ```ts
423
+ import { recordedTransport, specStream, usageLine } from 'ngx-json-render/testing';
424
+
425
+ const ui = injectUIStream({
426
+ api: '/api/generate',
427
+ fetch: recordedTransport([...specStream(expectedSpec), usageLine({ totalTokens: 15 })]),
428
+ });
429
+ ```
430
+
431
+ `specStream(spec)` writes the JSONL patch lines a model would emit to build
432
+ that spec, so a test says what it renders rather than how the wire spells it.
433
+ Pass a `Record<prompt, lines>` to answer each prompt differently (anything
434
+ else gets a 404 the hook surfaces as an error), or a function for more. The
435
+ options are `delayMs` for a visible pace, `fail` for an error response — as a
436
+ function, so it can be switched on and off between sends — and `promptOf` for
437
+ a request body neither hook sends. The default reads `injectUIStream`'s
438
+ `prompt` and `injectChatUI`'s last message.
439
+
226
440
  ## Spec features supported
227
441
 
228
442
  Full parity with the baseline json-render contract:
@@ -236,14 +450,42 @@ Full parity with the baseline json-render contract:
236
450
  | Computed / directives | `{ "$computed": "fmtDate", "args": {...} }`, custom `$`-directives |
237
451
  | Visibility | `"visible": { "$state": "/count", "gte": 5 }` (incl. `$and`/`$or`, `$item`, `$index`) |
238
452
  | Events → actions | `"on": { "press": { "action": "...", "params": {...}, "confirm": {...}, "onSuccess": ..., "onError": ... } }` |
239
- | Built-in actions | `setState`, `pushState` (with `$id`), `removeState`, `push`/`pop`, `validateForm` |
453
+ | Built-in actions | `setState`, `pushState` (with `$id`), `removeState`, `push`/`pop`, `validateForm`, `submitForm` |
240
454
  | Repeat | `"repeat": { "statePath": "/todos", "key": "id" }`, nested via `{ "$item": "..." }` |
241
455
  | Watch | `"watch": { "/country": { "action": "loadCities" } }` |
242
456
  | Slots | `"slots": { "header": ["title-el"] }` + `<jr-children slot="header" />` |
243
- | Validation | field checks via `ValidationConfig`, `validateForm`, `injectFieldValidation` |
457
+ | Validation | field checks via `ValidationConfig`, `validateForm` / `submitForm`, `injectFieldValidation` |
244
458
  | Confirm dialogs | built-in `<jr-confirm-dialog>` (auto-rendered) |
245
459
  | Devtools hooks | action observer + `data-jr-key` picker attributes |
246
460
 
461
+ ### Ready-made directives
462
+
463
+ `@json-render/directives` is written against the core, not against any one
464
+ renderer, so its `$format`, `$math`, `$concat`, `$count`, `$truncate`,
465
+ `$pluralize`, `$join` and `$t` work here as they are:
466
+
467
+ ```ts
468
+ import { createI18nDirective, standardDirectives } from '@json-render/directives';
469
+
470
+ readonly directives = [
471
+ ...standardDirectives,
472
+ createI18nDirective({ locale, messages }),
473
+ ];
474
+ ```
475
+
476
+ ```html
477
+ <json-render [spec]="spec()" [registry]="registry" [directives]="directives" />
478
+ ```
479
+
480
+ ```json
481
+ { "content": { "$format": "currency", "value": { "$state": "/price" }, "currency": "USD" } }
482
+ ```
483
+
484
+ A directive reads state through the same resolution as any other prop, so a
485
+ value it derives updates when that state does.
486
+ `projects/ngx-json-render/src/lib/directives.spec.ts` renders every one of
487
+ them through the renderer, which is what keeps this paragraph honest.
488
+
247
489
  ## State
248
490
 
249
491
  Each `<json-render>` owns a state store (JSON Pointer addressed). Seeding order: `store` input (controlled) → `state` input → `spec.state`.
@@ -262,6 +504,78 @@ Share one store across renderers (or drive it from your own state management) by
262
504
 
263
505
  If a catalog component renders `<input [value]="ctx.props().value">`, remember that one-way bindings do not re-assert the DOM when the bound value returns to its previously applied value while the user typed in between (e.g. `pushState` + `clearStatePath`). Sync imperatively instead — see `InputComponent` in the demo app for the pattern.
264
506
 
507
+ ## Confirmation dialogs
508
+
509
+ An action binding with a `confirm` field routes through a dialog before its
510
+ handler runs. The packaged one is a real modal — `role="dialog"` with
511
+ `aria-modal`, labelled by its title and described by its message, focus moved
512
+ onto _Cancel_ on open and returned to the trigger on close, Tab kept inside and
513
+ Escape cancelling.
514
+
515
+ Three levels of control, in the order you are likely to need them:
516
+
517
+ ```ts
518
+ // 1. Its two words, when the spec does not supply confirmLabel / cancelLabel.
519
+ { provide: JR_CONFIRM_LABELS, useValue: { confirm: 'Подтвердить', cancel: 'Отмена' } }
520
+ ```
521
+
522
+ ```css
523
+ /* 2. Its colours. Light and dark defaults ship; these override both. */
524
+ json-render { --jr-confirm-surface: #101418; --jr-confirm-accent: #4f9cf9; }
525
+ ```
526
+
527
+ Also `--jr-confirm-ink`, `--jr-confirm-muted`, `--jr-confirm-border`,
528
+ `--jr-confirm-scrim`, `--jr-confirm-danger`, `--jr-confirm-on-accent` and
529
+ `--jr-confirm-radius`.
530
+
531
+ ```ts
532
+ // 3. The whole dialog. Your component injects the context instead of taking
533
+ // inputs, the same way catalog components do.
534
+ @Component({
535
+ template: `<my-modal [title]="ctx.config.title" (ok)="ctx.confirm()" (dismiss)="ctx.cancel()" />`,
536
+ })
537
+ export class AppConfirm {
538
+ readonly ctx = injectConfirmContext();
539
+ }
540
+ // providers: [{ provide: JR_CONFIRM_DIALOG, useValue: AppConfirm }]
541
+ ```
542
+
543
+ ## Submitting a form
544
+
545
+ `validateForm` validates every bound field at once and writes
546
+ `{ valid, errors }` to `/formValidation` (or the `statePath` you pass). What it
547
+ cannot do is act on the answer: a model writing a submit button had to emit one
548
+ binding for the validation and hope the app's own handler re-checked the form.
549
+
550
+ `submitForm` is both halves in one binding — validate everything, and dispatch
551
+ the submit only if it all passes:
552
+
553
+ ```json
554
+ {
555
+ "type": "Button",
556
+ "props": { "label": "Save" },
557
+ "on": {
558
+ "press": {
559
+ "action": "submitForm",
560
+ "params": {
561
+ "action": "saveUser",
562
+ "params": { "email": { "$state": "/email" } }
563
+ },
564
+ "onSuccess": { "navigate": "/thanks" }
565
+ }
566
+ },
567
+ "children": []
568
+ }
569
+ ```
570
+
571
+ An invalid form stops there, with the errors written to state and every field
572
+ marked validated so its message is on screen. A valid one dispatches
573
+ `saveUser` as an ordinary action: the same handler lookup, the same `confirm`
574
+ (asked after validation — there is no point asking about a form that cannot be
575
+ submitted), the same `onSuccess` / `onError`, the same loading state. `params`
576
+ resolves `{ "$state": "/path" }` one level down, the way `pushState`'s `value`
577
+ does.
578
+
265
579
  ## Security
266
580
 
267
581
  Specs are attacker-shaped input: whatever produced one — a model, a prompt, a
@@ -278,9 +592,12 @@ text reaches the DOM through Angular interpolation. Script injection through a
278
592
  spec is not a thing you have to defend against.
279
593
 
280
594
  **A spec can only name actions you registered.** Built-ins (`setState`,
281
- `pushState`, `removeState`, `push`, `pop`, `validateForm`) are handled inside
282
- the renderer; every other action name is looked up in the `handlers` you pass.
283
- An unrecognised name logs a warning and does nothing.
595
+ `pushState`, `removeState`, `push`, `pop`, `validateForm`, `submitForm`) are
596
+ handled inside the renderer; every other action name is looked up in the
597
+ `handlers` you pass. An unrecognised name logs a warning and does nothing.
598
+ `submitForm` is no exception to this: the action it is told to submit goes
599
+ through the same lookup, so it gates a handler you registered rather than
600
+ reaching one you did not.
284
601
 
285
602
  The exception is `onAction`, which is a deliberate catch-all: when you pass it,
286
603
  **every** action name in the spec reaches it, including ones you never put in
@@ -314,10 +631,18 @@ handler, on the server.
314
631
 
315
632
  Components: `JsonRenderer` (`<json-render>`), `JrChildren`, `JrConfirmDialog`, `JrElement`, `JrRepeatScope`.
316
633
 
317
- Injectables/helpers: `injectRenderContext`, `injectRepeatScope`, `injectStateStore`, `injectStateValue`, `injectStateBinding`, `injectBoundProp`, `injectActions`, `injectAction`, `injectValidation`, `injectFieldValidation`, `injectUIStream`, `injectChatUI`, `injectDevtoolsActive`, `jsonRenderMessage`.
634
+ Injectables/helpers: `injectRenderContext`, `injectElementKey`, `injectRepeatScope`, `injectStateStore`, `injectStateValue`, `injectStateBinding`, `injectBoundProp`, `injectActions`, `injectAction`, `injectValidation`, `injectFieldValidation`, `injectUIStream`, `injectChatUI`, `injectDevtoolsActive`, `injectConfirmContext`, `jsonRenderMessage`, `isActionCancelled`, `checkSpec`.
635
+
636
+ Tokens: `JR_CONFIRM_DIALOG` (replace the confirmation dialog), `JR_CONFIRM_LABELS` (its two words), `CONFIRM_CONTEXT`, `RENDER_CONTEXT`, `REPEAT_SCOPE`.
637
+
638
+ `injectActions().execute()` rejects when the user dismisses a `confirm`
639
+ dialog, which is a normal gesture rather than a failure — `isActionCancelled(error)`
640
+ is how you tell the two apart.
318
641
 
319
642
  Registry & schema: `defineRegistry`, `createStoreSetState`, `schema`.
320
643
 
644
+ Testing (`ngx-json-render/testing`): `renderSpec`, `recordedTransport`, `specStream`, `usageLine`.
645
+
321
646
  Everything from `@json-render/core` (types, `createStateStore`, `nestedToFlat`, prompt builders, spec validators, SpecStream compiler) composes with this package; the most common symbols are re-exported.
322
647
 
323
648
  ## Renderer inputs
@@ -328,6 +653,7 @@ Everything from `@json-render/core` (types, `createStateStore`, `nestedToFlat`,
328
653
  | `registry` | `ComponentRegistry` | Catalog type → Angular component |
329
654
  | `loading` | `boolean` | Suppress missing-element warnings while streaming |
330
655
  | `fallback` | `Type<unknown>` | Component for unknown types |
656
+ | `validate` | `'off' \| 'warn' \| 'strict'` | Check the settled spec's structure (default `'off'`) |
331
657
  | `state` | `StateModel` | Initial state (uncontrolled; defaults to `spec.state`) |
332
658
  | `store` | `StateStore` | External store (controlled mode) |
333
659
  | `handlers` | `Record<string, ActionHandler>` | Action handlers |