ngx-json-render 0.1.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.
@@ -0,0 +1,2029 @@
1
+ import * as i0 from '@angular/core';
2
+ import { signal, Injectable, inject, computed, untracked, effect, isDevMode, input, output, ChangeDetectionStrategy, Component, DestroyRef, InjectionToken, Injector, forwardRef } from '@angular/core';
3
+ import { createStateStore, getByPath, runValidation, resolveAction, nextActionDispatchId, notifyActionDispatch, notifyActionSettle, executeAction, isDevtoolsActive, subscribeDevtoolsActive, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, createDirectiveRegistry, resolveRepeatStatePath, resolveRepeatItemStatePath, setByPath, removeByPath, SPEC_DATA_PART_TYPE, applySpecPatch, nestedToFlat, createMixedStreamParser, defineSchema } from '@json-render/core';
4
+ export { createStateStore, nestedToFlat } from '@json-render/core';
5
+ import { flattenToPointers } from '@json-render/core/store-utils';
6
+ import { NgComponentOutlet } from '@angular/common';
7
+
8
+ const EMPTY = signal(undefined, ...(ngDevMode ? [{ debugName: "EMPTY" }] : /* istanbul ignore next */ []));
9
+ /**
10
+ * Internal bridge between the `<json-render>` component's inputs and the
11
+ * renderer services / element tree. The renderer component replaces these
12
+ * signal references with its own input signals at construction time.
13
+ *
14
+ * @internal
15
+ */
16
+ class JsonRenderRootContext {
17
+ spec = EMPTY;
18
+ registry = EMPTY;
19
+ loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
20
+ fallback = EMPTY;
21
+ /** External store (controlled mode). */
22
+ store = EMPTY;
23
+ /** Initial state (uncontrolled mode); falls back to `spec.state`. */
24
+ initialState = signal({}, ...(ngDevMode ? [{ debugName: "initialState" }] : /* istanbul ignore next */ []));
25
+ handlers = EMPTY;
26
+ onAction = EMPTY;
27
+ navigate = EMPTY;
28
+ validationFunctions = EMPTY;
29
+ functions = EMPTY;
30
+ directiveRegistry = EMPTY;
31
+ /** Emits uncontrolled-mode state changes to the renderer output. */
32
+ emitStateChange = () => { };
33
+ /** Resolve a registry entry (component + slot metadata) for a type. */
34
+ resolveEntry(type) {
35
+ const raw = this.registry()?.[type] ?? this.fallback() ?? undefined;
36
+ if (!raw)
37
+ return undefined;
38
+ return typeof raw === 'function' ? { component: raw } : raw;
39
+ }
40
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderRootContext, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
41
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderRootContext });
42
+ }
43
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderRootContext, decorators: [{
44
+ type: Injectable
45
+ }] });
46
+
47
+ /**
48
+ * The state store of a `<json-render>` subtree.
49
+ *
50
+ * Wraps a core {@link StateStore} (either the internal in-memory store in
51
+ * uncontrolled mode, or an external store passed via the `store` input) and
52
+ * exposes the current state model as a signal.
53
+ *
54
+ * Inject it from catalog components or action handlers via
55
+ * {@link injectStateStore}.
56
+ */
57
+ class JsonRenderStateService {
58
+ root = inject(JsonRenderRootContext);
59
+ internalStore = createStateStore({});
60
+ changeListeners = new Set();
61
+ _state = signal({}, { ...(ngDevMode ? { debugName: "_state" } : /* istanbul ignore next */ {}), equal: () => false });
62
+ /** The current state model as a signal. */
63
+ state = this._state.asReadonly();
64
+ currentStore = computed(() => this.root.store() ?? this.internalStore, ...(ngDevMode ? [{ debugName: "currentStore" }] : /* istanbul ignore next */ []));
65
+ constructor() {
66
+ const initialMode = untracked(this.currentStore) === this.internalStore
67
+ ? 'uncontrolled'
68
+ : 'controlled';
69
+ let modeWarned = false;
70
+ // Keep the state signal in sync with whichever store is active.
71
+ effect((onCleanup) => {
72
+ const store = this.currentStore();
73
+ if (isDevMode() && !modeWarned) {
74
+ const mode = store === this.internalStore ? 'uncontrolled' : 'controlled';
75
+ if (mode !== initialMode) {
76
+ modeWarned = true;
77
+ console.warn(`[ngx-json-render] switching from ${initialMode} to ${mode} mode is not supported.`);
78
+ }
79
+ }
80
+ untracked(() => this._state.set(store.getSnapshot()));
81
+ const unsubscribe = store.subscribe(() => {
82
+ this._state.set(store.getSnapshot());
83
+ });
84
+ onCleanup(unsubscribe);
85
+ });
86
+ // Uncontrolled mode: when the (resolved) initial state changes — e.g.
87
+ // `spec.state` grows while streaming — diff it against the previous
88
+ // initial state and apply only the changed leaves to the store.
89
+ let prevFlat = {};
90
+ effect(() => {
91
+ if (this.root.store())
92
+ return;
93
+ const initialState = this.root.initialState() ?? {};
94
+ const nextFlat = Object.keys(initialState).length > 0
95
+ ? flattenToPointers(initialState)
96
+ : {};
97
+ const allKeys = new Set([
98
+ ...Object.keys(prevFlat),
99
+ ...Object.keys(nextFlat),
100
+ ]);
101
+ const updates = {};
102
+ for (const key of allKeys) {
103
+ if (prevFlat[key] !== nextFlat[key]) {
104
+ updates[key] = key in nextFlat ? nextFlat[key] : undefined;
105
+ }
106
+ }
107
+ prevFlat = nextFlat;
108
+ if (Object.keys(updates).length > 0) {
109
+ untracked(() => this.internalStore.update(updates));
110
+ }
111
+ });
112
+ }
113
+ /** Read a value by JSON Pointer path from the current state. */
114
+ get(path) {
115
+ return untracked(this.currentStore).get(path);
116
+ }
117
+ /** Write a value by JSON Pointer path and notify subscribers. */
118
+ set(path, value) {
119
+ const store = untracked(this.currentStore);
120
+ const prev = store.getSnapshot();
121
+ const prevValue = getByPath(prev, path);
122
+ store.set(path, value);
123
+ if (prevValue !== value) {
124
+ const changes = [{ path, value }];
125
+ this.notifyChanges(changes);
126
+ if (!untracked(this.root.store) && store.getSnapshot() !== prev) {
127
+ this.root.emitStateChange(changes);
128
+ }
129
+ }
130
+ }
131
+ /** Write multiple values at once (single notification). */
132
+ update(updates) {
133
+ const store = untracked(this.currentStore);
134
+ const prev = store.getSnapshot();
135
+ store.update(updates);
136
+ const changes = [];
137
+ for (const [path, value] of Object.entries(updates)) {
138
+ if (getByPath(prev, path) !== value) {
139
+ changes.push({ path, value });
140
+ }
141
+ }
142
+ if (changes.length > 0) {
143
+ this.notifyChanges(changes);
144
+ if (!untracked(this.root.store) && store.getSnapshot() !== prev) {
145
+ this.root.emitStateChange(changes);
146
+ }
147
+ }
148
+ }
149
+ /** Return the full state object (non-reactive read). */
150
+ getSnapshot() {
151
+ return untracked(this.currentStore).getSnapshot();
152
+ }
153
+ /**
154
+ * Register a listener called with the list of changed paths whenever state
155
+ * is written through this service (element `watch` fields rely on this).
156
+ * Returns an unsubscribe function.
157
+ */
158
+ subscribeChanges(listener) {
159
+ this.changeListeners.add(listener);
160
+ return () => {
161
+ this.changeListeners.delete(listener);
162
+ };
163
+ }
164
+ notifyChanges(changes) {
165
+ for (const listener of this.changeListeners) {
166
+ listener(changes);
167
+ }
168
+ }
169
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderStateService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
170
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderStateService });
171
+ }
172
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderStateService, decorators: [{
173
+ type: Injectable
174
+ }], ctorParameters: () => [] });
175
+ /**
176
+ * Inject the state store of the nearest `<json-render>` renderer.
177
+ * Must be called from within the renderer's subtree (e.g. a catalog
178
+ * component) or a component that provides {@link JsonRenderStateService}.
179
+ */
180
+ function injectStateStore() {
181
+ return inject(JsonRenderStateService);
182
+ }
183
+ /** Reactive read of a state value by JSON Pointer path. */
184
+ function injectStateValue(path) {
185
+ const store = injectStateStore();
186
+ const getPath = typeof path === 'function' ? path : () => path;
187
+ return computed(() => getByPath(store.state(), getPath()));
188
+ }
189
+ /**
190
+ * Reactive two-way binding to a state path: a value signal plus a setter
191
+ * that writes back to the same path.
192
+ */
193
+ function injectStateBinding(path) {
194
+ const store = injectStateStore();
195
+ const getPath = typeof path === 'function' ? path : () => path;
196
+ return {
197
+ value: computed(() => getByPath(store.state(), getPath())),
198
+ set: (value) => store.set(getPath(), value),
199
+ };
200
+ }
201
+ /**
202
+ * Two-way bound prop helper for catalog components, mirroring `useBoundProp`
203
+ * from the other renderers: the value comes from the already-resolved prop,
204
+ * and the setter writes back to the bound state path (no-op if not bound).
205
+ *
206
+ * @example
207
+ * ```ts
208
+ * const ctx = injectRenderContext<{ value?: string }>();
209
+ * const bound = injectBoundProp<string>(
210
+ * () => ctx.props().value,
211
+ * () => ctx.bindings()?.['value'],
212
+ * );
213
+ * // template: <input [value]="bound.value() ?? ''" (input)="bound.set($any($event.target).value)" />
214
+ * ```
215
+ */
216
+ function injectBoundProp(propValue, bindingPath) {
217
+ const store = injectStateStore();
218
+ return {
219
+ value: computed(propValue),
220
+ set: (value) => {
221
+ const path = bindingPath();
222
+ if (path)
223
+ store.set(path, value);
224
+ },
225
+ };
226
+ }
227
+
228
+ function dynamicArgsEqual(a, b) {
229
+ if (a === b)
230
+ return true;
231
+ if (!a || !b)
232
+ return false;
233
+ const keysA = Object.keys(a);
234
+ const keysB = Object.keys(b);
235
+ if (keysA.length !== keysB.length)
236
+ return false;
237
+ for (const key of keysA) {
238
+ const va = a[key];
239
+ const vb = b[key];
240
+ if (va === vb)
241
+ continue;
242
+ if (typeof va === 'object' &&
243
+ va !== null &&
244
+ typeof vb === 'object' &&
245
+ vb !== null) {
246
+ const sa = va['$state'];
247
+ const sb = vb['$state'];
248
+ if (typeof sa === 'string' && sa === sb)
249
+ continue;
250
+ }
251
+ return false;
252
+ }
253
+ return true;
254
+ }
255
+ function validationConfigEqual(a, b) {
256
+ if (a === b)
257
+ return true;
258
+ if (a.validateOn !== b.validateOn)
259
+ return false;
260
+ const ac = a.checks ?? [];
261
+ const bc = b.checks ?? [];
262
+ if (ac.length !== bc.length)
263
+ return false;
264
+ for (let i = 0; i < ac.length; i++) {
265
+ const ca = ac[i];
266
+ const cb = bc[i];
267
+ if (ca.type !== cb.type)
268
+ return false;
269
+ if (ca.message !== cb.message)
270
+ return false;
271
+ if (!dynamicArgsEqual(ca.args, cb.args))
272
+ return false;
273
+ }
274
+ return true;
275
+ }
276
+ /**
277
+ * Form validation state of a `<json-render>` subtree. Fields register their
278
+ * {@link ValidationConfig}; the built-in `validateForm` action validates all
279
+ * registered fields and writes the result to state.
280
+ */
281
+ class JsonRenderValidationService {
282
+ root = inject(JsonRenderRootContext);
283
+ state = inject(JsonRenderStateService);
284
+ _fieldStates = signal({}, ...(ngDevMode ? [{ debugName: "_fieldStates" }] : /* istanbul ignore next */ []));
285
+ _fieldConfigs = signal({}, ...(ngDevMode ? [{ debugName: "_fieldConfigs" }] : /* istanbul ignore next */ []));
286
+ /** Validation state per registered field path. */
287
+ fieldStates = this._fieldStates.asReadonly();
288
+ get customFunctions() {
289
+ return untracked(this.root.validationFunctions) ?? {};
290
+ }
291
+ /** Register (or update) a field's validation config. */
292
+ registerField(path, config) {
293
+ const prev = untracked(this._fieldConfigs);
294
+ const existing = prev[path];
295
+ if (existing && validationConfigEqual(existing, config))
296
+ return;
297
+ this._fieldConfigs.set({ ...prev, [path]: config });
298
+ }
299
+ /** Validate a single field and record the result. */
300
+ validate(path, config) {
301
+ const currentState = this.state.getSnapshot();
302
+ const segments = path.split('/').filter(Boolean);
303
+ let value = currentState;
304
+ for (const seg of segments) {
305
+ if (value != null && typeof value === 'object') {
306
+ value = value[seg];
307
+ }
308
+ else {
309
+ value = undefined;
310
+ break;
311
+ }
312
+ }
313
+ const result = runValidation(config, {
314
+ value,
315
+ stateModel: currentState,
316
+ customFunctions: this.customFunctions,
317
+ });
318
+ const prev = untracked(this._fieldStates);
319
+ this._fieldStates.set({
320
+ ...prev,
321
+ [path]: {
322
+ touched: prev[path]?.touched ?? true,
323
+ validated: true,
324
+ result,
325
+ },
326
+ });
327
+ return result;
328
+ }
329
+ /** Mark a field as touched. */
330
+ touch(path) {
331
+ const prev = untracked(this._fieldStates);
332
+ this._fieldStates.set({
333
+ ...prev,
334
+ [path]: {
335
+ ...prev[path],
336
+ touched: true,
337
+ validated: prev[path]?.validated ?? false,
338
+ result: prev[path]?.result ?? null,
339
+ },
340
+ });
341
+ }
342
+ /** Clear a field's validation state. */
343
+ clear(path) {
344
+ const { [path]: _, ...rest } = untracked(this._fieldStates);
345
+ this._fieldStates.set(rest);
346
+ }
347
+ /** Validate all registered fields. Returns whether all are valid. */
348
+ validateAll() {
349
+ let allValid = true;
350
+ for (const [path, config] of Object.entries(untracked(this._fieldConfigs))) {
351
+ const result = this.validate(path, config);
352
+ if (!result.valid) {
353
+ allValid = false;
354
+ }
355
+ }
356
+ return allValid;
357
+ }
358
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderValidationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
359
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderValidationService });
360
+ }
361
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderValidationService, decorators: [{
362
+ type: Injectable
363
+ }] });
364
+ /** Inject the validation service of the nearest `<json-render>` renderer. */
365
+ function injectValidation() {
366
+ return inject(JsonRenderValidationService);
367
+ }
368
+ /**
369
+ * Field-level validation helper for catalog input components: registers the
370
+ * config and exposes the field's validation state as signals.
371
+ */
372
+ function injectFieldValidation(path, config) {
373
+ const validation = injectValidation();
374
+ const getPath = typeof path === 'function' ? path : () => path;
375
+ const getConfig = typeof config === 'function' ? config : () => config;
376
+ effect(() => {
377
+ const p = getPath();
378
+ const c = getConfig();
379
+ if (p && c) {
380
+ validation.registerField(p, c);
381
+ }
382
+ });
383
+ const state = computed(() => {
384
+ const current = validation.fieldStates()[getPath()];
385
+ return current ?? { touched: false, validated: false, result: null };
386
+ }, ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
387
+ return {
388
+ state,
389
+ validate: () => validation.validate(getPath(), getConfig() ?? { checks: [] }),
390
+ touch: () => validation.touch(getPath()),
391
+ clear: () => validation.clear(getPath()),
392
+ errors: computed(() => state().result?.errors ?? []),
393
+ isValid: computed(() => state().result?.valid ?? true),
394
+ };
395
+ }
396
+
397
+ let idCounter = 0;
398
+ function generateUniqueId() {
399
+ idCounter += 1;
400
+ return `${Date.now()}-${idCounter}`;
401
+ }
402
+ function deepResolveValue(value, get) {
403
+ if (value === null || value === undefined)
404
+ return value;
405
+ if (value === '$id') {
406
+ return generateUniqueId();
407
+ }
408
+ if (typeof value === 'object' && !Array.isArray(value)) {
409
+ const obj = value;
410
+ const keys = Object.keys(obj);
411
+ if (keys.length === 1 && typeof obj['$state'] === 'string') {
412
+ return get(obj['$state']);
413
+ }
414
+ if (keys.length === 1 && '$id' in obj) {
415
+ return generateUniqueId();
416
+ }
417
+ }
418
+ if (Array.isArray(value)) {
419
+ return value.map((item) => deepResolveValue(item, get));
420
+ }
421
+ if (typeof value === 'object') {
422
+ const resolved = {};
423
+ for (const [key, val] of Object.entries(value)) {
424
+ resolved[key] = deepResolveValue(val, get);
425
+ }
426
+ return resolved;
427
+ }
428
+ return value;
429
+ }
430
+ /**
431
+ * Action dispatcher of a `<json-render>` subtree.
432
+ *
433
+ * Executes {@link ActionBinding}s: built-in actions (`setState`, `pushState`,
434
+ * `removeState`, `push`, `pop`, `validateForm`) are handled internally;
435
+ * everything else is routed to the host-provided `handlers` (or the
436
+ * `onAction` catch-all), honoring `confirm`, `onSuccess`, and `onError`.
437
+ */
438
+ class JsonRenderActionsService {
439
+ root = inject(JsonRenderRootContext);
440
+ state = inject(JsonRenderStateService);
441
+ validation = inject(JsonRenderValidationService, {
442
+ optional: true,
443
+ });
444
+ extraHandlers = signal({}, ...(ngDevMode ? [{ debugName: "extraHandlers" }] : /* istanbul ignore next */ []));
445
+ _loadingActions = signal(new Set(), ...(ngDevMode ? [{ debugName: "_loadingActions" }] : /* istanbul ignore next */ []));
446
+ _pendingConfirmation = signal(null, ...(ngDevMode ? [{ debugName: "_pendingConfirmation" }] : /* istanbul ignore next */ []));
447
+ /** Names of actions currently executing. */
448
+ loadingActions = this._loadingActions.asReadonly();
449
+ /** The confirmation currently awaiting user input, if any. */
450
+ pendingConfirmation = this._pendingConfirmation.asReadonly();
451
+ /** All registered handlers (host handlers + runtime registrations). */
452
+ get handlers() {
453
+ return {
454
+ ...(untracked(this.root.handlers) ?? {}),
455
+ ...untracked(this.extraHandlers),
456
+ };
457
+ }
458
+ /** Register an additional action handler at runtime. */
459
+ registerHandler(name, handler) {
460
+ this.extraHandlers.set({
461
+ ...untracked(this.extraHandlers),
462
+ [name]: handler,
463
+ });
464
+ }
465
+ /** Execute an action binding. */
466
+ async execute(binding) {
467
+ const resolved = resolveAction(binding, this.state.getSnapshot());
468
+ const get = (path) => this.state.get(path);
469
+ const set = (path, value) => this.state.set(path, value);
470
+ // --- devtools / observer hooks ---
471
+ const dispatchId = nextActionDispatchId();
472
+ const dispatchedAt = Date.now();
473
+ notifyActionDispatch({
474
+ id: dispatchId,
475
+ name: resolved.action,
476
+ params: resolved.params,
477
+ at: dispatchedAt,
478
+ });
479
+ let ok = true;
480
+ let error = undefined;
481
+ try {
482
+ if (resolved.action === 'setState' && resolved.params) {
483
+ const statePath = resolved.params['statePath'];
484
+ const value = resolved.params['value'];
485
+ if (statePath) {
486
+ set(statePath, value);
487
+ }
488
+ return;
489
+ }
490
+ if (resolved.action === 'pushState' && resolved.params) {
491
+ const statePath = resolved.params['statePath'];
492
+ const rawValue = resolved.params['value'];
493
+ if (statePath) {
494
+ const resolvedValue = deepResolveValue(rawValue, get);
495
+ const arr = get(statePath) ?? [];
496
+ set(statePath, [...arr, resolvedValue]);
497
+ const clearStatePath = resolved.params['clearStatePath'];
498
+ if (clearStatePath) {
499
+ set(clearStatePath, '');
500
+ }
501
+ }
502
+ return;
503
+ }
504
+ if (resolved.action === 'removeState' && resolved.params) {
505
+ const statePath = resolved.params['statePath'];
506
+ const index = resolved.params['index'];
507
+ if (statePath !== undefined && index !== undefined) {
508
+ const arr = get(statePath) ?? [];
509
+ set(statePath, arr.filter((_, i) => i !== index));
510
+ }
511
+ return;
512
+ }
513
+ if (resolved.action === 'push' && resolved.params) {
514
+ const screen = resolved.params['screen'];
515
+ if (screen) {
516
+ const currentScreen = get('/currentScreen');
517
+ const navStack = get('/navStack') ?? [];
518
+ if (currentScreen) {
519
+ set('/navStack', [...navStack, currentScreen]);
520
+ }
521
+ else {
522
+ set('/navStack', [...navStack, '']);
523
+ }
524
+ set('/currentScreen', screen);
525
+ }
526
+ return;
527
+ }
528
+ if (resolved.action === 'pop') {
529
+ const navStack = get('/navStack') ?? [];
530
+ if (navStack.length > 0) {
531
+ const previousScreen = navStack[navStack.length - 1];
532
+ set('/navStack', navStack.slice(0, -1));
533
+ if (previousScreen) {
534
+ set('/currentScreen', previousScreen);
535
+ }
536
+ else {
537
+ set('/currentScreen', undefined);
538
+ }
539
+ }
540
+ return;
541
+ }
542
+ if (resolved.action === 'validateForm') {
543
+ if (!this.validation) {
544
+ console.warn('validateForm action was dispatched but no JsonRenderValidationService is available.');
545
+ return;
546
+ }
547
+ const valid = this.validation.validateAll();
548
+ const errors = {};
549
+ for (const [path, fs] of Object.entries(untracked(this.validation.fieldStates))) {
550
+ if (fs.result && !fs.result.valid) {
551
+ errors[path] = fs.result.errors;
552
+ }
553
+ }
554
+ const statePath = resolved.params?.['statePath'] || '/formValidation';
555
+ set(statePath, { valid, errors });
556
+ return;
557
+ }
558
+ const handler = this.lookupHandler(resolved.action);
559
+ if (!handler) {
560
+ console.warn(`No handler registered for action: ${resolved.action}`);
561
+ return;
562
+ }
563
+ if (resolved.confirm) {
564
+ return new Promise((resolve, reject) => {
565
+ this._pendingConfirmation.set({
566
+ action: resolved,
567
+ handler,
568
+ resolve: () => {
569
+ this._pendingConfirmation.set(null);
570
+ resolve();
571
+ },
572
+ reject: () => {
573
+ this._pendingConfirmation.set(null);
574
+ reject(new Error('Action cancelled'));
575
+ },
576
+ });
577
+ }).then(() => this.runHandler(resolved, handler));
578
+ }
579
+ await this.runHandler(resolved, handler);
580
+ }
581
+ catch (err) {
582
+ ok = false;
583
+ error = err;
584
+ throw err;
585
+ }
586
+ finally {
587
+ const now = Date.now();
588
+ notifyActionSettle({
589
+ id: dispatchId,
590
+ name: resolved.action,
591
+ ok,
592
+ at: now,
593
+ durationMs: now - dispatchedAt,
594
+ error,
595
+ });
596
+ }
597
+ }
598
+ /** Confirm the pending confirmation dialog. */
599
+ confirm() {
600
+ untracked(this._pendingConfirmation)?.resolve();
601
+ }
602
+ /** Cancel the pending confirmation dialog. */
603
+ cancel() {
604
+ untracked(this._pendingConfirmation)?.reject();
605
+ }
606
+ lookupHandler(name) {
607
+ const direct = this.handlers[name];
608
+ if (direct)
609
+ return direct;
610
+ // Catch-all: route unknown actions to the `onAction` input when provided.
611
+ const onAction = untracked(this.root.onAction);
612
+ if (onAction) {
613
+ return (params) => onAction(name, params);
614
+ }
615
+ return undefined;
616
+ }
617
+ async runHandler(resolved, handler) {
618
+ this._loadingActions.set(new Set(untracked(this._loadingActions)).add(resolved.action));
619
+ try {
620
+ await executeAction({
621
+ action: resolved,
622
+ handler,
623
+ setState: (path, value) => this.state.set(path, value),
624
+ navigate: untracked(this.root.navigate) ?? undefined,
625
+ executeAction: async (binding) => {
626
+ await this.execute(binding);
627
+ },
628
+ });
629
+ }
630
+ finally {
631
+ const next = new Set(untracked(this._loadingActions));
632
+ next.delete(resolved.action);
633
+ this._loadingActions.set(next);
634
+ }
635
+ }
636
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderActionsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
637
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderActionsService });
638
+ }
639
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderActionsService, decorators: [{
640
+ type: Injectable
641
+ }] });
642
+ /** Inject the actions service of the nearest `<json-render>` renderer. */
643
+ function injectActions() {
644
+ return inject(JsonRenderActionsService);
645
+ }
646
+ /**
647
+ * Convenience helper for executing a fixed action binding, mirroring
648
+ * `useAction` from the other renderers.
649
+ */
650
+ function injectAction(binding) {
651
+ const actions = injectActions();
652
+ return {
653
+ execute: () => actions.execute(binding),
654
+ isLoading: computed(() => actions.loadingActions().has(binding.action)),
655
+ };
656
+ }
657
+
658
+ /**
659
+ * Default confirmation dialog shown for action bindings with a `confirm`
660
+ * field. Rendered automatically by `<json-render>`; can also be used
661
+ * standalone with a custom action flow.
662
+ */
663
+ class JrConfirmDialog {
664
+ config = input.required(...(ngDevMode ? [{ debugName: "config" }] : /* istanbul ignore next */ []));
665
+ confirmed = output();
666
+ cancelled = output();
667
+ isDanger = computed(() => this.config().variant === 'danger', ...(ngDevMode ? [{ debugName: "isDanger" }] : /* istanbul ignore next */ []));
668
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrConfirmDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
669
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.22", type: JrConfirmDialog, isStandalone: true, selector: "jr-confirm-dialog", inputs: { config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { confirmed: "confirmed", cancelled: "cancelled" }, ngImport: i0, template: `
670
+ <div
671
+ style="position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
672
+ (click)="cancelled.emit()"
673
+ >
674
+ <div
675
+ style="background-color: white; border-radius: 8px; padding: 24px; max-width: 400px; width: 100%; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1)"
676
+ (click)="$event.stopPropagation()"
677
+ >
678
+ <h3 style="margin: 0 0 8px 0; font-size: 18px; font-weight: 600">
679
+ {{ config().title }}
680
+ </h3>
681
+ <p style="margin: 0 0 24px 0; color: #6b7280">{{ config().message }}</p>
682
+ <div style="display: flex; gap: 12px; justify-content: flex-end">
683
+ <button
684
+ type="button"
685
+ (click)="cancelled.emit()"
686
+ style="padding: 8px 16px; border-radius: 6px; border: 1px solid #d1d5db; background-color: white; cursor: pointer"
687
+ >
688
+ {{ config().cancelLabel ?? 'Cancel' }}
689
+ </button>
690
+ <button
691
+ type="button"
692
+ (click)="confirmed.emit()"
693
+ [style.background-color]="isDanger() ? '#dc2626' : '#3b82f6'"
694
+ style="padding: 8px 16px; border-radius: 6px; border: none; color: white; cursor: pointer"
695
+ >
696
+ {{ config().confirmLabel ?? 'Confirm' }}
697
+ </button>
698
+ </div>
699
+ </div>
700
+ </div>
701
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
702
+ }
703
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrConfirmDialog, decorators: [{
704
+ type: Component,
705
+ args: [{
706
+ selector: 'jr-confirm-dialog',
707
+ changeDetection: ChangeDetectionStrategy.OnPush,
708
+ template: `
709
+ <div
710
+ style="position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
711
+ (click)="cancelled.emit()"
712
+ >
713
+ <div
714
+ style="background-color: white; border-radius: 8px; padding: 24px; max-width: 400px; width: 100%; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1)"
715
+ (click)="$event.stopPropagation()"
716
+ >
717
+ <h3 style="margin: 0 0 8px 0; font-size: 18px; font-weight: 600">
718
+ {{ config().title }}
719
+ </h3>
720
+ <p style="margin: 0 0 24px 0; color: #6b7280">{{ config().message }}</p>
721
+ <div style="display: flex; gap: 12px; justify-content: flex-end">
722
+ <button
723
+ type="button"
724
+ (click)="cancelled.emit()"
725
+ style="padding: 8px 16px; border-radius: 6px; border: 1px solid #d1d5db; background-color: white; cursor: pointer"
726
+ >
727
+ {{ config().cancelLabel ?? 'Cancel' }}
728
+ </button>
729
+ <button
730
+ type="button"
731
+ (click)="confirmed.emit()"
732
+ [style.background-color]="isDanger() ? '#dc2626' : '#3b82f6'"
733
+ style="padding: 8px 16px; border-radius: 6px; border: none; color: white; cursor: pointer"
734
+ >
735
+ {{ config().confirmLabel ?? 'Confirm' }}
736
+ </button>
737
+ </div>
738
+ </div>
739
+ </div>
740
+ `,
741
+ }]
742
+ }], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: true }] }], confirmed: [{ type: i0.Output, args: ["confirmed"] }], cancelled: [{ type: i0.Output, args: ["cancelled"] }] } });
743
+
744
+ /**
745
+ * Reactive mirror of the json-render devtools-active flag. When active, the
746
+ * renderer wraps each element with a `data-jr-key` attribute for the picker.
747
+ */
748
+ function injectDevtoolsActive() {
749
+ const active = signal(isDevtoolsActive(), ...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
750
+ const unsubscribe = subscribeDevtoolsActive(() => active.set(isDevtoolsActive()));
751
+ inject(DestroyRef).onDestroy(unsubscribe);
752
+ return active.asReadonly();
753
+ }
754
+
755
+ /**
756
+ * The render context of the element currently being rendered.
757
+ * Provided by the renderer for every catalog component instance.
758
+ */
759
+ const RENDER_CONTEXT = new InjectionToken('ngx-json-render RENDER_CONTEXT');
760
+ /**
761
+ * The current repeat scope. Present only for elements rendered inside a
762
+ * `repeat` block.
763
+ */
764
+ const REPEAT_SCOPE = new InjectionToken('ngx-json-render REPEAT_SCOPE');
765
+ /**
766
+ * Inject the render context inside a catalog component.
767
+ *
768
+ * @example
769
+ * ```ts
770
+ * @Component({
771
+ * selector: 'app-button',
772
+ * template: `<button (click)="ctx.emit('press')">{{ ctx.props().label }}</button>`,
773
+ * })
774
+ * export class ButtonComponent {
775
+ * readonly ctx = injectRenderContext<{ label: string }>();
776
+ * }
777
+ * ```
778
+ */
779
+ function injectRenderContext() {
780
+ const ctx = inject(RENDER_CONTEXT, { optional: true });
781
+ if (!ctx) {
782
+ throw new Error('injectRenderContext() must be used inside a component rendered by <json-render>');
783
+ }
784
+ return ctx;
785
+ }
786
+ /**
787
+ * Inject the current repeat scope, or `null` when the component is not
788
+ * rendered inside a `repeat` block.
789
+ */
790
+ function injectRepeatScope() {
791
+ return inject(REPEAT_SCOPE, { optional: true });
792
+ }
793
+
794
+ const warnedSlots = new Set();
795
+ /**
796
+ * Renders a single spec element: evaluates visibility, resolves prop
797
+ * expressions against the state model, wires event/watch bindings, and
798
+ * instantiates the catalog component with a {@link RenderContext} injector.
799
+ *
800
+ * @internal Used by `<json-render>` and `<jr-children>`.
801
+ */
802
+ class JrElement {
803
+ elementKey = input.required(...(ngDevMode ? [{ debugName: "elementKey" }] : /* istanbul ignore next */ []));
804
+ root = inject(JsonRenderRootContext);
805
+ state = inject(JsonRenderStateService);
806
+ actions = inject(JsonRenderActionsService);
807
+ repeatScope = inject(REPEAT_SCOPE, { optional: true });
808
+ devtoolsActive = injectDevtoolsActive();
809
+ /** The raw (unresolved) element from the spec. */
810
+ rawElement = computed(() => this.root.spec()?.elements?.[this.elementKey()], ...(ngDevMode ? [{ debugName: "rawElement" }] : /* istanbul ignore next */ []));
811
+ /** Prop/visibility resolution context (state + repeat scope + extensions). */
812
+ resolutionCtx = computed(() => {
813
+ const scope = this.repeatScope;
814
+ return {
815
+ stateModel: this.state.state(),
816
+ ...(scope
817
+ ? {
818
+ repeatItem: scope.item(),
819
+ repeatIndex: scope.index(),
820
+ repeatBasePath: scope.basePath(),
821
+ }
822
+ : {}),
823
+ functions: this.root.functions() ?? {},
824
+ directives: this.root.directiveRegistry(),
825
+ };
826
+ }, ...(ngDevMode ? [{ debugName: "resolutionCtx" }] : /* istanbul ignore next */ []));
827
+ visible = computed(() => {
828
+ const el = this.rawElement();
829
+ if (!el || el.visible === undefined)
830
+ return true;
831
+ return evaluateVisibility(el.visible, this.resolutionCtx());
832
+ }, ...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
833
+ /** The element with all prop expressions resolved. */
834
+ resolvedElement = computed(() => {
835
+ const el = this.rawElement();
836
+ if (!el)
837
+ return undefined;
838
+ return { ...el, props: resolveElementProps(el.props ?? {}, this.resolutionCtx()) };
839
+ }, ...(ngDevMode ? [{ debugName: "resolvedElement" }] : /* istanbul ignore next */ []));
840
+ /** Two-way binding paths ($bindState / $bindItem) by prop name. */
841
+ bindings = computed(() => {
842
+ const el = this.rawElement();
843
+ return el ? resolveBindings(el.props ?? {}, this.resolutionCtx()) : undefined;
844
+ }, ...(ngDevMode ? [{ debugName: "bindings" }] : /* istanbul ignore next */ []));
845
+ entry = computed(() => {
846
+ const el = this.rawElement();
847
+ return el ? this.root.resolveEntry(el.type) : undefined;
848
+ }, ...(ngDevMode ? [{ debugName: "entry" }] : /* istanbul ignore next */ []));
849
+ component = computed(() => this.entry()?.component ?? null, ...(ngDevMode ? [{ debugName: "component" }] : /* istanbul ignore next */ []));
850
+ devtoolsKey = computed(() => this.devtoolsActive() ? this.elementKey() : null, ...(ngDevMode ? [{ debugName: "devtoolsKey" }] : /* istanbul ignore next */ []));
851
+ renderCtx = {
852
+ element: this.resolvedElement,
853
+ props: computed(() => this.resolvedElement()?.props ?? {}),
854
+ emit: (event) => void this.emitEvent(event),
855
+ on: (event) => this.eventHandle(event),
856
+ bindings: this.bindings,
857
+ loading: computed(() => this.root.loading()),
858
+ setBound: (prop, value) => {
859
+ const path = untracked(this.bindings)?.[prop];
860
+ if (path) {
861
+ this.state.set(path, value);
862
+ }
863
+ else if (isDevMode()) {
864
+ console.warn(`[ngx-json-render] setBound("${prop}"): prop has no $bindState/$bindItem binding`);
865
+ }
866
+ },
867
+ };
868
+ outletInjector = Injector.create({
869
+ providers: [{ provide: RENDER_CONTEXT, useValue: this.renderCtx }],
870
+ parent: inject(Injector),
871
+ });
872
+ constructor() {
873
+ // Warn (once per type) about unknown component types.
874
+ const warnedTypes = new Set();
875
+ effect(() => {
876
+ const el = this.rawElement();
877
+ if (!el || this.entry() || warnedTypes.has(el.type))
878
+ return;
879
+ warnedTypes.add(el.type);
880
+ console.warn(`No renderer for component type: ${el.type}`);
881
+ });
882
+ // Warn about unknown / default slots when the registry carries metadata.
883
+ effect(() => {
884
+ const el = this.rawElement();
885
+ const meta = this.entry();
886
+ if (!el?.slots || !meta?.slots)
887
+ return;
888
+ const available = new Set(meta.slots);
889
+ for (const slotName of Object.keys(el.slots)) {
890
+ const warnKey = `${el.type}:${slotName}`;
891
+ if (warnedSlots.has(warnKey))
892
+ continue;
893
+ if (slotName === 'default') {
894
+ warnedSlots.add(warnKey);
895
+ console.warn(`[json-render] Component "${el.type}" uses slots.default. Use "children" for default slot content.`);
896
+ }
897
+ else if (!available.has(slotName)) {
898
+ warnedSlots.add(warnKey);
899
+ console.warn(`[json-render] Unknown slot "${slotName}" on component "${el.type}". Available slots: ${meta.slots.join(', ')}`);
900
+ }
901
+ }
902
+ });
903
+ // Watch effect: fire actions when watched state paths change.
904
+ effect((onCleanup) => {
905
+ const watchConfig = this.rawElement()?.watch;
906
+ if (!watchConfig)
907
+ return;
908
+ const paths = Object.keys(watchConfig);
909
+ if (paths.length === 0)
910
+ return;
911
+ const unsubscribe = this.state.subscribeChanges((changes) => {
912
+ const changedPaths = new Set(changes.map((change) => change.path));
913
+ void (async () => {
914
+ for (const path of paths) {
915
+ if (!changedPaths.has(path))
916
+ continue;
917
+ const binding = watchConfig[path];
918
+ if (!binding)
919
+ continue;
920
+ const bindings = Array.isArray(binding) ? binding : [binding];
921
+ for (const b of bindings) {
922
+ if (!b.params) {
923
+ await this.actions.execute(b);
924
+ continue;
925
+ }
926
+ const liveCtx = this.liveResolutionCtx();
927
+ const resolved = {};
928
+ for (const [key, val] of Object.entries(b.params)) {
929
+ resolved[key] = resolveActionParam(val, liveCtx);
930
+ }
931
+ await this.actions.execute({ ...b, params: resolved });
932
+ }
933
+ }
934
+ })().catch(console.error);
935
+ });
936
+ onCleanup(unsubscribe);
937
+ });
938
+ }
939
+ /**
940
+ * Resolution context with a live state snapshot, so `$state` references in
941
+ * later actions of a chain see mutations from earlier ones.
942
+ */
943
+ liveResolutionCtx() {
944
+ return {
945
+ ...untracked(this.resolutionCtx),
946
+ stateModel: this.state.getSnapshot(),
947
+ };
948
+ }
949
+ async emitEvent(eventName) {
950
+ const el = untracked(this.rawElement);
951
+ const binding = el?.on?.[eventName];
952
+ if (!binding)
953
+ return;
954
+ const actionBindings = Array.isArray(binding) ? binding : [binding];
955
+ for (const b of actionBindings) {
956
+ if (!b.params) {
957
+ await this.actions.execute(b);
958
+ continue;
959
+ }
960
+ const liveCtx = this.liveResolutionCtx();
961
+ const resolved = {};
962
+ for (const [key, val] of Object.entries(b.params)) {
963
+ resolved[key] = resolveActionParam(val, liveCtx);
964
+ }
965
+ await this.actions.execute({ ...b, params: resolved });
966
+ }
967
+ }
968
+ eventHandle(eventName) {
969
+ const el = this.rawElement();
970
+ const binding = el?.on?.[eventName];
971
+ if (!binding) {
972
+ return { emit: () => { }, shouldPreventDefault: false, bound: false };
973
+ }
974
+ const actionBindings = Array.isArray(binding) ? binding : [binding];
975
+ return {
976
+ emit: () => void this.emitEvent(eventName),
977
+ shouldPreventDefault: actionBindings.some((b) => b.preventDefault),
978
+ bound: true,
979
+ };
980
+ }
981
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrElement, deps: [], target: i0.ɵɵFactoryTarget.Component });
982
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: JrElement, isStandalone: true, selector: "jr-element", inputs: { elementKey: { classPropertyName: "elementKey", publicName: "elementKey", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
983
+ @if (rawElement() && visible() && component()) {
984
+ @if (devtoolsKey(); as dk) {
985
+ <span [attr.data-jr-key]="dk" style="display: contents">
986
+ <ng-container *ngComponentOutlet="component(); injector: outletInjector" />
987
+ </span>
988
+ } @else {
989
+ <ng-container *ngComponentOutlet="component(); injector: outletInjector" />
990
+ }
991
+ }
992
+ `, isInline: true, styles: [":host{display:contents}\n"], dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
993
+ }
994
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrElement, decorators: [{
995
+ type: Component,
996
+ args: [{ selector: 'jr-element', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgComponentOutlet], template: `
997
+ @if (rawElement() && visible() && component()) {
998
+ @if (devtoolsKey(); as dk) {
999
+ <span [attr.data-jr-key]="dk" style="display: contents">
1000
+ <ng-container *ngComponentOutlet="component(); injector: outletInjector" />
1001
+ </span>
1002
+ } @else {
1003
+ <ng-container *ngComponentOutlet="component(); injector: outletInjector" />
1004
+ }
1005
+ }
1006
+ `, styles: [":host{display:contents}\n"] }]
1007
+ }], ctorParameters: () => [], propDecorators: { elementKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "elementKey", required: true }] }] } });
1008
+
1009
+ /**
1010
+ * Renders a json-render {@link Spec} using a registry of Angular components.
1011
+ *
1012
+ * The renderer owns the state store, action dispatcher, and validation state
1013
+ * of its subtree (all injectable from catalog components). Pass an external
1014
+ * {@link StateStore} via `store` for controlled mode or to share state across
1015
+ * renderers.
1016
+ *
1017
+ * @example
1018
+ * ```html
1019
+ * <json-render
1020
+ * [spec]="spec()"
1021
+ * [registry]="registry"
1022
+ * [handlers]="handlers"
1023
+ * [loading]="isStreaming()"
1024
+ * (stateChange)="onStateChange($event)"
1025
+ * />
1026
+ * ```
1027
+ */
1028
+ class JsonRenderer {
1029
+ /** The UI spec to render (may be partial while streaming). */
1030
+ spec = input.required(...(ngDevMode ? [{ debugName: "spec" }] : /* istanbul ignore next */ []));
1031
+ /** Component registry mapping catalog type names to Angular components. */
1032
+ registry = input.required(...(ngDevMode ? [{ debugName: "registry" }] : /* istanbul ignore next */ []));
1033
+ /** Whether the spec is currently loading/streaming. */
1034
+ loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
1035
+ /** Fallback component for unknown types. */
1036
+ fallback = input(null, ...(ngDevMode ? [{ debugName: "fallback" }] : /* istanbul ignore next */ []));
1037
+ /**
1038
+ * Initial state model (uncontrolled mode). Defaults to `spec.state`.
1039
+ * Ignored when `store` is provided.
1040
+ */
1041
+ state = input(undefined, ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
1042
+ /** External state store (controlled mode). */
1043
+ store = input(null, ...(ngDevMode ? [{ debugName: "store" }] : /* istanbul ignore next */ []));
1044
+ /** Action handlers by action name. */
1045
+ handlers = input(undefined, ...(ngDevMode ? [{ debugName: "handlers" }] : /* istanbul ignore next */ []));
1046
+ /** Catch-all action handler for actions without a dedicated handler. */
1047
+ onAction = input(null, ...(ngDevMode ? [{ debugName: "onAction" }] : /* istanbul ignore next */ []));
1048
+ /** Navigation function used by `onSuccess: { navigate }` handlers. */
1049
+ navigate = input(null, ...(ngDevMode ? [{ debugName: "navigate" }] : /* istanbul ignore next */ []));
1050
+ /** Custom validation functions. */
1051
+ validationFunctions = input(undefined, ...(ngDevMode ? [{ debugName: "validationFunctions" }] : /* istanbul ignore next */ []));
1052
+ /** Named functions for `$computed` expressions in props. */
1053
+ functions = input(undefined, ...(ngDevMode ? [{ debugName: "functions" }] : /* istanbul ignore next */ []));
1054
+ /** Custom directives for user-defined `$`-prefixed dynamic values. */
1055
+ directives = input(undefined, ...(ngDevMode ? [{ debugName: "directives" }] : /* istanbul ignore next */ []));
1056
+ /** Emits state changes in uncontrolled mode. */
1057
+ stateChange = output();
1058
+ /** The state store of this renderer (also injectable in the subtree). */
1059
+ stateStore;
1060
+ /** The action dispatcher of this renderer. */
1061
+ actions;
1062
+ constructor() {
1063
+ const root = inject(JsonRenderRootContext);
1064
+ root.spec = this.spec;
1065
+ root.registry = this.registry;
1066
+ root.loading = this.loading;
1067
+ root.fallback = this.fallback;
1068
+ root.store = this.store;
1069
+ root.initialState = computed(() => this.state() ?? this.spec()?.state ?? {}, ...(ngDevMode ? [{ debugName: "initialState" }] : /* istanbul ignore next */ []));
1070
+ root.handlers = this.handlers;
1071
+ root.onAction = this.onAction;
1072
+ root.navigate = this.navigate;
1073
+ root.validationFunctions = this.validationFunctions;
1074
+ root.functions = this.functions;
1075
+ root.directiveRegistry = computed(() => {
1076
+ const definitions = this.directives();
1077
+ return definitions ? createDirectiveRegistry(definitions) : undefined;
1078
+ }, ...(ngDevMode ? [{ debugName: "directiveRegistry" }] : /* istanbul ignore next */ []));
1079
+ root.emitStateChange = (changes) => this.stateChange.emit(changes);
1080
+ // Instantiate the subtree services now that the root context is wired.
1081
+ this.stateStore = inject(JsonRenderStateService);
1082
+ inject(JsonRenderValidationService);
1083
+ this.actions = inject(JsonRenderActionsService);
1084
+ }
1085
+ rootKey = computed(() => {
1086
+ const spec = this.spec();
1087
+ return spec?.root && spec.elements?.[spec.root] ? spec.root : null;
1088
+ }, ...(ngDevMode ? [{ debugName: "rootKey" }] : /* istanbul ignore next */ []));
1089
+ pendingConfirm = computed(() => this.actions.pendingConfirmation()?.action.confirm ?? null, ...(ngDevMode ? [{ debugName: "pendingConfirm" }] : /* istanbul ignore next */ []));
1090
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderer, deps: [], target: i0.ɵɵFactoryTarget.Component });
1091
+ 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 }, 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: [
1092
+ JsonRenderRootContext,
1093
+ JsonRenderStateService,
1094
+ JsonRenderValidationService,
1095
+ JsonRenderActionsService,
1096
+ ], ngImport: i0, template: `
1097
+ @if (rootKey(); as key) {
1098
+ <jr-element [elementKey]="key" />
1099
+ }
1100
+ @if (pendingConfirm(); as confirm) {
1101
+ <jr-confirm-dialog
1102
+ [config]="confirm"
1103
+ (confirmed)="actions.confirm()"
1104
+ (cancelled)="actions.cancel()"
1105
+ />
1106
+ }
1107
+ `, isInline: true, styles: [":host{display:contents}\n"], dependencies: [{ kind: "component", type: JrElement, selector: "jr-element", inputs: ["elementKey"] }, { kind: "component", type: JrConfirmDialog, selector: "jr-confirm-dialog", inputs: ["config"], outputs: ["confirmed", "cancelled"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1108
+ }
1109
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderer, decorators: [{
1110
+ type: Component,
1111
+ args: [{ selector: 'json-render', changeDetection: ChangeDetectionStrategy.OnPush, imports: [JrElement, JrConfirmDialog], providers: [
1112
+ JsonRenderRootContext,
1113
+ JsonRenderStateService,
1114
+ JsonRenderValidationService,
1115
+ JsonRenderActionsService,
1116
+ ], template: `
1117
+ @if (rootKey(); as key) {
1118
+ <jr-element [elementKey]="key" />
1119
+ }
1120
+ @if (pendingConfirm(); as confirm) {
1121
+ <jr-confirm-dialog
1122
+ [config]="confirm"
1123
+ (confirmed)="actions.confirm()"
1124
+ (cancelled)="actions.cancel()"
1125
+ />
1126
+ }
1127
+ `, styles: [":host{display:contents}\n"] }]
1128
+ }], 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 }] }], 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"] }] } });
1129
+
1130
+ /**
1131
+ * Provides the repeat scope (item, index, absolute base path) to the elements
1132
+ * rendered inside a `repeat` block.
1133
+ *
1134
+ * @internal Used by `<jr-children>`; not intended for direct use.
1135
+ */
1136
+ class JrRepeatScope {
1137
+ item = input.required(...(ngDevMode ? [{ debugName: "item" }] : /* istanbul ignore next */ []));
1138
+ index = input.required(...(ngDevMode ? [{ debugName: "index" }] : /* istanbul ignore next */ []));
1139
+ basePath = input.required(...(ngDevMode ? [{ debugName: "basePath" }] : /* istanbul ignore next */ []));
1140
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrRepeatScope, deps: [], target: i0.ɵɵFactoryTarget.Component });
1141
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.22", type: JrRepeatScope, isStandalone: true, selector: "jr-repeat-scope", inputs: { item: { classPropertyName: "item", publicName: "item", isSignal: true, isRequired: true, transformFunction: null }, index: { classPropertyName: "index", publicName: "index", isSignal: true, isRequired: true, transformFunction: null }, basePath: { classPropertyName: "basePath", publicName: "basePath", isSignal: true, isRequired: true, transformFunction: null } }, providers: [
1142
+ { provide: REPEAT_SCOPE, useExisting: forwardRef(() => JrRepeatScope) },
1143
+ ], ngImport: i0, template: '<ng-content />', isInline: true, styles: [":host{display:contents}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1144
+ }
1145
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrRepeatScope, decorators: [{
1146
+ type: Component,
1147
+ args: [{ selector: 'jr-repeat-scope', changeDetection: ChangeDetectionStrategy.OnPush, providers: [
1148
+ { provide: REPEAT_SCOPE, useExisting: forwardRef(() => JrRepeatScope) },
1149
+ ], template: '<ng-content />', styles: [":host{display:contents}\n"] }]
1150
+ }], propDecorators: { item: [{ type: i0.Input, args: [{ isSignal: true, alias: "item", required: true }] }], index: [{ type: i0.Input, args: [{ isSignal: true, alias: "index", required: true }] }], basePath: [{ type: i0.Input, args: [{ isSignal: true, alias: "basePath", required: true }] }] } });
1151
+
1152
+ /**
1153
+ * Renders the children of the current element. Place it inside a catalog
1154
+ * component's template where nested content should appear — like a
1155
+ * `<router-outlet>` for the spec tree.
1156
+ *
1157
+ * - Default (no `slot`): renders `element.children`, honoring the element's
1158
+ * `repeat` field (one pass per item of the referenced state array, with the
1159
+ * proper repeat scope for `$item` / `$index` / `$bindItem` expressions).
1160
+ * - With `slot`: renders the element keys of `element.slots[slot]`.
1161
+ *
1162
+ * @example
1163
+ * ```html
1164
+ * <div class="card">
1165
+ * <h3>{{ ctx.props().title }}</h3>
1166
+ * <jr-children />
1167
+ * </div>
1168
+ * ```
1169
+ */
1170
+ class JrChildren {
1171
+ /** Named slot to render instead of the default children. */
1172
+ slot = input(null, ...(ngDevMode ? [{ debugName: "slot" }] : /* istanbul ignore next */ []));
1173
+ ctx = injectRenderContext();
1174
+ root = inject(JsonRenderRootContext);
1175
+ state = inject(JsonRenderStateService);
1176
+ // The repeat scope enclosing the host element (scopes created by this
1177
+ // element's own repeat live below, in the template).
1178
+ parentScope = inject(REPEAT_SCOPE, { optional: true });
1179
+ childKeys = computed(() => {
1180
+ const el = this.ctx.element();
1181
+ if (!el)
1182
+ return [];
1183
+ const slot = this.slot();
1184
+ if (slot)
1185
+ return el.slots?.[slot] ?? [];
1186
+ return el.children ?? [];
1187
+ }, ...(ngDevMode ? [{ debugName: "childKeys" }] : /* istanbul ignore next */ []));
1188
+ repeat = computed(() => {
1189
+ if (this.slot())
1190
+ return undefined;
1191
+ return this.ctx.element()?.repeat;
1192
+ }, ...(ngDevMode ? [{ debugName: "repeat" }] : /* istanbul ignore next */ []));
1193
+ repeatBasePath = computed(() => {
1194
+ const rep = this.repeat();
1195
+ if (!rep)
1196
+ return undefined;
1197
+ const resolved = resolveRepeatStatePath(rep.statePath, this.parentScope?.basePath());
1198
+ if (resolved === undefined) {
1199
+ console.warn('[ngx-json-render] $item in repeat.statePath used outside of a repeat scope');
1200
+ }
1201
+ return resolved;
1202
+ }, ...(ngDevMode ? [{ debugName: "repeatBasePath" }] : /* istanbul ignore next */ []));
1203
+ repeatItems = computed(() => {
1204
+ const basePath = this.repeatBasePath();
1205
+ if (basePath === undefined)
1206
+ return [];
1207
+ return (getByPath(this.state.state(), basePath) ?? []);
1208
+ }, ...(ngDevMode ? [{ debugName: "repeatItems" }] : /* istanbul ignore next */ []));
1209
+ constructor() {
1210
+ // Warn (once per key) about children referencing missing elements.
1211
+ const warned = new Set();
1212
+ effect(() => {
1213
+ if (this.root.loading())
1214
+ return;
1215
+ const spec = this.root.spec();
1216
+ if (!spec)
1217
+ return;
1218
+ for (const key of this.childKeys()) {
1219
+ if (!spec.elements?.[key] && !warned.has(key)) {
1220
+ warned.add(key);
1221
+ console.warn(`[json-render] Missing element "${key}" referenced as child of "${untracked(this.ctx.element)?.type}". This element will not render.`);
1222
+ }
1223
+ }
1224
+ });
1225
+ }
1226
+ itemPath(index) {
1227
+ return resolveRepeatItemStatePath(this.repeatBasePath(), index);
1228
+ }
1229
+ trackItem(index, item) {
1230
+ const rep = untracked(this.repeat);
1231
+ if (rep?.key && typeof item === 'object' && item !== null) {
1232
+ return item[rep.key] ?? index;
1233
+ }
1234
+ return index;
1235
+ }
1236
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrChildren, deps: [], target: i0.ɵɵFactoryTarget.Component });
1237
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: JrChildren, isStandalone: true, selector: "jr-children", inputs: { slot: { classPropertyName: "slot", publicName: "slot", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
1238
+ @if (repeat()) {
1239
+ @for (item of repeatItems(); track trackItem($index, item)) {
1240
+ <jr-repeat-scope
1241
+ [item]="item"
1242
+ [index]="$index"
1243
+ [basePath]="itemPath($index)"
1244
+ >
1245
+ @for (key of childKeys(); track key) {
1246
+ <jr-element [elementKey]="key" />
1247
+ }
1248
+ </jr-repeat-scope>
1249
+ }
1250
+ } @else {
1251
+ @for (key of childKeys(); track key) {
1252
+ <jr-element [elementKey]="key" />
1253
+ }
1254
+ }
1255
+ `, isInline: true, styles: [":host{display:contents}\n"], dependencies: [{ kind: "component", type: JrElement, selector: "jr-element", inputs: ["elementKey"] }, { kind: "component", type: JrRepeatScope, selector: "jr-repeat-scope", inputs: ["item", "index", "basePath"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1256
+ }
1257
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrChildren, decorators: [{
1258
+ type: Component,
1259
+ args: [{ selector: 'jr-children', changeDetection: ChangeDetectionStrategy.OnPush, imports: [JrElement, JrRepeatScope], template: `
1260
+ @if (repeat()) {
1261
+ @for (item of repeatItems(); track trackItem($index, item)) {
1262
+ <jr-repeat-scope
1263
+ [item]="item"
1264
+ [index]="$index"
1265
+ [basePath]="itemPath($index)"
1266
+ >
1267
+ @for (key of childKeys(); track key) {
1268
+ <jr-element [elementKey]="key" />
1269
+ }
1270
+ </jr-repeat-scope>
1271
+ }
1272
+ } @else {
1273
+ @for (key of childKeys(); track key) {
1274
+ <jr-element [elementKey]="key" />
1275
+ }
1276
+ }
1277
+ `, styles: [":host{display:contents}\n"] }]
1278
+ }], ctorParameters: () => [], propDecorators: { slot: [{ type: i0.Input, args: [{ isSignal: true, alias: "slot", required: false }] }] } });
1279
+
1280
+ /**
1281
+ * Create a registry from a catalog with components and/or actions.
1282
+ *
1283
+ * Component keys are type-checked against the catalog, and slot metadata from
1284
+ * the catalog is carried into the registry for dev-mode slot warnings.
1285
+ *
1286
+ * @example
1287
+ * ```ts
1288
+ * const { registry, handlers } = defineRegistry(catalog, {
1289
+ * components: { Card: CardComponent, Button: ButtonComponent },
1290
+ * actions: {
1291
+ * refresh: async (params, setState) => {
1292
+ * setState((prev) => ({ ...prev, refreshedAt: Date.now() }));
1293
+ * },
1294
+ * },
1295
+ * });
1296
+ * ```
1297
+ */
1298
+ function defineRegistry(catalog, options) {
1299
+ const catalogComponents = catalog.data?.components;
1300
+ // Build the component registry, attaching slot metadata from the catalog.
1301
+ const registry = {};
1302
+ if (options.components) {
1303
+ for (const [name, component] of Object.entries(options.components)) {
1304
+ const entry = { component };
1305
+ const slots = catalogComponents?.[name]?.slots;
1306
+ if (slots)
1307
+ entry.slots = slots;
1308
+ registry[name] = entry;
1309
+ }
1310
+ }
1311
+ // Build action helpers.
1312
+ const actionMap = options.actions
1313
+ ? Object.entries(options.actions)
1314
+ : [];
1315
+ const handlers = (getSetState, getState) => {
1316
+ const result = {};
1317
+ for (const [name, actionFn] of actionMap) {
1318
+ result[name] = async (params) => {
1319
+ const setState = getSetState();
1320
+ const state = getState();
1321
+ if (setState) {
1322
+ await actionFn(params, setState, state);
1323
+ }
1324
+ };
1325
+ }
1326
+ return result;
1327
+ };
1328
+ const executeAction = async (actionName, params, setState, state = {}) => {
1329
+ const entry = actionMap.find(([name]) => name === actionName);
1330
+ if (entry) {
1331
+ await entry[1](params, setState, state);
1332
+ }
1333
+ else {
1334
+ console.warn(`Unknown action: ${actionName}`);
1335
+ }
1336
+ };
1337
+ return { registry, handlers, executeAction };
1338
+ }
1339
+ /**
1340
+ * Build a {@link SetState} (whole-state updater) on top of a path-based
1341
+ * store. The updater's result is diffed against the previous state and only
1342
+ * changed leaves are written, preserving fine-grained reactivity.
1343
+ *
1344
+ * Works with both a core {@link StateStore} and the renderer's injected
1345
+ * `JsonRenderStateService`.
1346
+ */
1347
+ function createStoreSetState(store) {
1348
+ return (updater) => {
1349
+ const prev = store.getSnapshot();
1350
+ const next = updater(prev);
1351
+ if (next === prev)
1352
+ return;
1353
+ const prevFlat = flattenToPointers(prev);
1354
+ const nextFlat = flattenToPointers(next);
1355
+ const allKeys = new Set([
1356
+ ...Object.keys(prevFlat),
1357
+ ...Object.keys(nextFlat),
1358
+ ]);
1359
+ const updates = {};
1360
+ for (const key of allKeys) {
1361
+ if (prevFlat[key] !== nextFlat[key]) {
1362
+ updates[key] = key in nextFlat ? nextFlat[key] : undefined;
1363
+ }
1364
+ }
1365
+ if (Object.keys(updates).length > 0) {
1366
+ store.update(updates);
1367
+ }
1368
+ };
1369
+ }
1370
+
1371
+ /**
1372
+ * Parse a single JSON line (patch or metadata).
1373
+ */
1374
+ function parseLine(line) {
1375
+ try {
1376
+ const trimmed = line.trim();
1377
+ if (!trimmed || trimmed.startsWith('//')) {
1378
+ return null;
1379
+ }
1380
+ const parsed = JSON.parse(trimmed);
1381
+ // Check for usage metadata
1382
+ if (parsed.__meta === 'usage') {
1383
+ return {
1384
+ type: 'usage',
1385
+ usage: {
1386
+ promptTokens: parsed.promptTokens ?? 0,
1387
+ completionTokens: parsed.completionTokens ?? 0,
1388
+ totalTokens: parsed.totalTokens ?? 0,
1389
+ },
1390
+ };
1391
+ }
1392
+ return { type: 'patch', patch: parsed };
1393
+ }
1394
+ catch {
1395
+ return null;
1396
+ }
1397
+ }
1398
+ /**
1399
+ * Set a value at a spec path (for add/replace operations).
1400
+ */
1401
+ function setSpecValue(newSpec, path, value) {
1402
+ if (path === '/root') {
1403
+ newSpec.root = value;
1404
+ return;
1405
+ }
1406
+ if (path === '/state') {
1407
+ newSpec.state = value;
1408
+ return;
1409
+ }
1410
+ if (path.startsWith('/state/')) {
1411
+ if (!newSpec.state)
1412
+ newSpec.state = {};
1413
+ const statePath = path.slice('/state'.length); // e.g. "/posts"
1414
+ setByPath(newSpec.state, statePath, value);
1415
+ return;
1416
+ }
1417
+ if (path.startsWith('/elements/')) {
1418
+ const pathParts = path.slice('/elements/'.length).split('/');
1419
+ const elementKey = pathParts[0];
1420
+ if (!elementKey)
1421
+ return;
1422
+ if (pathParts.length === 1) {
1423
+ newSpec.elements[elementKey] = value;
1424
+ }
1425
+ else {
1426
+ const element = newSpec.elements[elementKey];
1427
+ if (element) {
1428
+ const propPath = '/' + pathParts.slice(1).join('/');
1429
+ const newElement = { ...element };
1430
+ setByPath(newElement, propPath, value);
1431
+ newSpec.elements[elementKey] = newElement;
1432
+ }
1433
+ }
1434
+ }
1435
+ }
1436
+ /**
1437
+ * Remove a value at a spec path.
1438
+ */
1439
+ function removeSpecValue(newSpec, path) {
1440
+ if (path === '/state') {
1441
+ delete newSpec.state;
1442
+ return;
1443
+ }
1444
+ if (path.startsWith('/state/') && newSpec.state) {
1445
+ const statePath = path.slice('/state'.length);
1446
+ removeByPath(newSpec.state, statePath);
1447
+ return;
1448
+ }
1449
+ if (path.startsWith('/elements/')) {
1450
+ const pathParts = path.slice('/elements/'.length).split('/');
1451
+ const elementKey = pathParts[0];
1452
+ if (!elementKey)
1453
+ return;
1454
+ if (pathParts.length === 1) {
1455
+ const { [elementKey]: _, ...rest } = newSpec.elements;
1456
+ newSpec.elements = rest;
1457
+ }
1458
+ else {
1459
+ const element = newSpec.elements[elementKey];
1460
+ if (element) {
1461
+ const propPath = '/' + pathParts.slice(1).join('/');
1462
+ const newElement = { ...element };
1463
+ removeByPath(newElement, propPath);
1464
+ newSpec.elements[elementKey] = newElement;
1465
+ }
1466
+ }
1467
+ }
1468
+ }
1469
+ /**
1470
+ * Get a value at a spec path.
1471
+ */
1472
+ function getSpecValue(spec, path) {
1473
+ if (path === '/root')
1474
+ return spec.root;
1475
+ if (path === '/state')
1476
+ return spec.state;
1477
+ if (path.startsWith('/state/') && spec.state) {
1478
+ const statePath = path.slice('/state'.length);
1479
+ return getByPath(spec.state, statePath);
1480
+ }
1481
+ return getByPath(spec, path);
1482
+ }
1483
+ /**
1484
+ * Apply an RFC 6902 JSON patch to the current spec, returning a new spec
1485
+ * object (structural sharing for untouched elements).
1486
+ * Supports add, remove, replace, move, copy, and test operations.
1487
+ */
1488
+ function applyPatch(spec, patch) {
1489
+ const newSpec = {
1490
+ ...spec,
1491
+ elements: { ...spec.elements },
1492
+ ...(spec.state ? { state: { ...spec.state } } : {}),
1493
+ };
1494
+ switch (patch.op) {
1495
+ case 'add':
1496
+ case 'replace': {
1497
+ setSpecValue(newSpec, patch.path, patch.value);
1498
+ break;
1499
+ }
1500
+ case 'remove': {
1501
+ removeSpecValue(newSpec, patch.path);
1502
+ break;
1503
+ }
1504
+ case 'move': {
1505
+ if (!patch.from)
1506
+ break;
1507
+ const moveValue = getSpecValue(newSpec, patch.from);
1508
+ removeSpecValue(newSpec, patch.from);
1509
+ setSpecValue(newSpec, patch.path, moveValue);
1510
+ break;
1511
+ }
1512
+ case 'copy': {
1513
+ if (!patch.from)
1514
+ break;
1515
+ const copyValue = getSpecValue(newSpec, patch.from);
1516
+ setSpecValue(newSpec, patch.path, copyValue);
1517
+ break;
1518
+ }
1519
+ case 'test': {
1520
+ // test is a no-op for rendering purposes (validation only)
1521
+ break;
1522
+ }
1523
+ }
1524
+ return newSpec;
1525
+ }
1526
+ /**
1527
+ * Streaming UI generation. POSTs `{ prompt, context, currentSpec }` to the
1528
+ * endpoint and progressively applies the returned JSONL patch stream to the
1529
+ * `spec` signal, so partial UIs render as they arrive.
1530
+ *
1531
+ * Must be called in an injection context (aborts in-flight requests on
1532
+ * destroy).
1533
+ *
1534
+ * @example
1535
+ * ```ts
1536
+ * export class GeneratePage {
1537
+ * readonly ui = injectUIStream({ api: '/api/generate' });
1538
+ * }
1539
+ * // template:
1540
+ * // <json-render [spec]="ui.spec()" [registry]="registry" [loading]="ui.isStreaming()" />
1541
+ * ```
1542
+ */
1543
+ function injectUIStream(options) {
1544
+ const spec = signal(null, ...(ngDevMode ? [{ debugName: "spec" }] : /* istanbul ignore next */ []));
1545
+ const isStreaming = signal(false, ...(ngDevMode ? [{ debugName: "isStreaming" }] : /* istanbul ignore next */ []));
1546
+ const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
1547
+ const usage = signal(null, ...(ngDevMode ? [{ debugName: "usage" }] : /* istanbul ignore next */ []));
1548
+ const rawLines = signal([], ...(ngDevMode ? [{ debugName: "rawLines" }] : /* istanbul ignore next */ []));
1549
+ let abortController = null;
1550
+ inject(DestroyRef).onDestroy(() => {
1551
+ abortController?.abort();
1552
+ });
1553
+ const clear = () => {
1554
+ spec.set(null);
1555
+ error.set(null);
1556
+ };
1557
+ const send = async (prompt, context) => {
1558
+ // Abort any existing request
1559
+ abortController?.abort();
1560
+ abortController = new AbortController();
1561
+ isStreaming.set(true);
1562
+ error.set(null);
1563
+ usage.set(null);
1564
+ rawLines.set([]);
1565
+ // Start with previous spec if provided, otherwise empty spec
1566
+ const previousSpec = context?.['previousSpec'];
1567
+ let currentSpec = previousSpec && previousSpec.root
1568
+ ? { ...previousSpec, elements: { ...previousSpec.elements } }
1569
+ : { root: '', elements: {} };
1570
+ spec.set(currentSpec);
1571
+ const handleLine = (trimmed) => {
1572
+ const result = parseLine(trimmed);
1573
+ if (!result)
1574
+ return;
1575
+ if (result.type === 'usage') {
1576
+ usage.set(result.usage);
1577
+ }
1578
+ else {
1579
+ rawLines.update((prev) => [...prev, trimmed]);
1580
+ currentSpec = applyPatch(currentSpec, result.patch);
1581
+ spec.set(currentSpec);
1582
+ }
1583
+ };
1584
+ try {
1585
+ const response = await fetch(options.api, {
1586
+ method: 'POST',
1587
+ headers: { 'Content-Type': 'application/json' },
1588
+ body: JSON.stringify({
1589
+ prompt,
1590
+ context,
1591
+ currentSpec,
1592
+ }),
1593
+ signal: abortController.signal,
1594
+ });
1595
+ if (!response.ok) {
1596
+ // Try to parse JSON error response for better error messages
1597
+ let errorMessage = `HTTP error: ${response.status}`;
1598
+ try {
1599
+ const errorData = await response.json();
1600
+ if (errorData.message) {
1601
+ errorMessage = errorData.message;
1602
+ }
1603
+ else if (errorData.error) {
1604
+ errorMessage = errorData.error;
1605
+ }
1606
+ }
1607
+ catch {
1608
+ // Ignore JSON parsing errors, use default message
1609
+ }
1610
+ throw new Error(errorMessage);
1611
+ }
1612
+ const reader = response.body?.getReader();
1613
+ if (!reader) {
1614
+ throw new Error('No response body');
1615
+ }
1616
+ const decoder = new TextDecoder();
1617
+ let buffer = '';
1618
+ while (true) {
1619
+ const { done, value } = await reader.read();
1620
+ if (done)
1621
+ break;
1622
+ buffer += decoder.decode(value, { stream: true });
1623
+ // Process complete lines
1624
+ const lines = buffer.split('\n');
1625
+ buffer = lines.pop() ?? '';
1626
+ for (const line of lines) {
1627
+ const trimmed = line.trim();
1628
+ if (!trimmed)
1629
+ continue;
1630
+ handleLine(trimmed);
1631
+ }
1632
+ }
1633
+ // Process any remaining buffer
1634
+ if (buffer.trim()) {
1635
+ handleLine(buffer.trim());
1636
+ }
1637
+ options.onComplete?.(currentSpec);
1638
+ }
1639
+ catch (err) {
1640
+ if (err.name === 'AbortError') {
1641
+ return;
1642
+ }
1643
+ const resolvedError = err instanceof Error ? err : new Error(String(err));
1644
+ error.set(resolvedError);
1645
+ options.onError?.(resolvedError);
1646
+ }
1647
+ finally {
1648
+ isStreaming.set(false);
1649
+ }
1650
+ };
1651
+ return {
1652
+ spec: spec.asReadonly(),
1653
+ isStreaming: isStreaming.asReadonly(),
1654
+ error: error.asReadonly(),
1655
+ usage: usage.asReadonly(),
1656
+ rawLines: rawLines.asReadonly(),
1657
+ send,
1658
+ clear,
1659
+ };
1660
+ }
1661
+ /**
1662
+ * Convert a flat element list to a Spec.
1663
+ * Input elements use key/parentKey to establish identity and relationships.
1664
+ * Output spec uses the map-based format where key is the map entry key
1665
+ * and parent-child relationships are expressed through children arrays.
1666
+ */
1667
+ function flatToTree(elements) {
1668
+ const elementMap = {};
1669
+ let root = '';
1670
+ // First pass: add all elements to map
1671
+ for (const element of elements) {
1672
+ elementMap[element.key] = {
1673
+ type: element.type,
1674
+ props: element.props,
1675
+ children: [],
1676
+ visible: element.visible,
1677
+ };
1678
+ }
1679
+ // Second pass: build parent-child relationships
1680
+ for (const element of elements) {
1681
+ if (element.parentKey) {
1682
+ const parent = elementMap[element.parentKey];
1683
+ if (parent) {
1684
+ if (!parent.children) {
1685
+ parent.children = [];
1686
+ }
1687
+ parent.children.push(element.key);
1688
+ }
1689
+ }
1690
+ else {
1691
+ root = element.key;
1692
+ }
1693
+ }
1694
+ return { root, elements: elementMap };
1695
+ }
1696
+ /**
1697
+ * Type guard that validates a data part payload looks like a valid
1698
+ * {@link SpecDataPart} before we cast it.
1699
+ */
1700
+ function isSpecDataPart(data) {
1701
+ if (typeof data !== 'object' || data === null)
1702
+ return false;
1703
+ const obj = data;
1704
+ switch (obj['type']) {
1705
+ case 'patch':
1706
+ return typeof obj['patch'] === 'object' && obj['patch'] !== null;
1707
+ case 'flat':
1708
+ case 'nested':
1709
+ return typeof obj['spec'] === 'object' && obj['spec'] !== null;
1710
+ default:
1711
+ return false;
1712
+ }
1713
+ }
1714
+ /**
1715
+ * Build a `Spec` by replaying all spec data parts from a message's
1716
+ * parts array (AI SDK `UIMessage.parts`). Returns `null` if no spec data
1717
+ * parts are present.
1718
+ */
1719
+ function buildSpecFromParts(parts) {
1720
+ const spec = { root: '', elements: {} };
1721
+ let hasSpec = false;
1722
+ for (const part of parts) {
1723
+ if (part.type === SPEC_DATA_PART_TYPE) {
1724
+ if (!isSpecDataPart(part.data))
1725
+ continue;
1726
+ const payload = part.data;
1727
+ if (payload.type === 'patch') {
1728
+ hasSpec = true;
1729
+ applySpecPatch(spec, payload.patch);
1730
+ }
1731
+ else if (payload.type === 'flat') {
1732
+ hasSpec = true;
1733
+ Object.assign(spec, payload.spec);
1734
+ }
1735
+ else if (payload.type === 'nested') {
1736
+ hasSpec = true;
1737
+ const flat = nestedToFlat(payload.spec);
1738
+ Object.assign(spec, flat);
1739
+ }
1740
+ }
1741
+ }
1742
+ return hasSpec ? spec : null;
1743
+ }
1744
+ /**
1745
+ * Extract and join all text content from a message's parts array.
1746
+ */
1747
+ function getTextFromParts(parts) {
1748
+ return parts
1749
+ .filter((p) => p.type === 'text' && typeof p.text === 'string')
1750
+ .map((p) => p.text.trim())
1751
+ .filter(Boolean)
1752
+ .join('\n\n');
1753
+ }
1754
+ /**
1755
+ * Extract both the json-render spec and the text content from a message's
1756
+ * parts array, as memoized signals. Angular counterpart of
1757
+ * `useJsonRenderMessage` from the other renderers.
1758
+ *
1759
+ * @example
1760
+ * ```ts
1761
+ * readonly msg = jsonRenderMessage(() => this.message().parts);
1762
+ * // template: @if (msg.hasSpec()) { <json-render [spec]="msg.spec()" ... /> }
1763
+ * ```
1764
+ */
1765
+ function jsonRenderMessage(parts) {
1766
+ const result = computed(() => {
1767
+ const p = parts();
1768
+ return {
1769
+ spec: buildSpecFromParts(p),
1770
+ text: getTextFromParts(p),
1771
+ };
1772
+ }, ...(ngDevMode ? [{ debugName: "result" }] : /* istanbul ignore next */ []));
1773
+ return {
1774
+ spec: computed(() => result().spec),
1775
+ text: computed(() => result().text),
1776
+ hasSpec: computed(() => {
1777
+ const s = result().spec;
1778
+ return s !== null && Object.keys(s.elements || {}).length > 0;
1779
+ }),
1780
+ };
1781
+ }
1782
+ let chatMessageIdCounter = 0;
1783
+ function generateChatId() {
1784
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
1785
+ return crypto.randomUUID();
1786
+ }
1787
+ chatMessageIdCounter += 1;
1788
+ return `msg-${Date.now()}-${chatMessageIdCounter}`;
1789
+ }
1790
+ /**
1791
+ * Chat + GenUI: manages a multi-turn conversation where each assistant
1792
+ * message can contain both conversational text and a json-render UI spec.
1793
+ * The full message history is sent to the endpoint and the streamed response
1794
+ * is split into text lines and JSONL patch lines.
1795
+ *
1796
+ * Must be called in an injection context.
1797
+ */
1798
+ function injectChatUI(options) {
1799
+ const messages = signal([], ...(ngDevMode ? [{ debugName: "messages" }] : /* istanbul ignore next */ []));
1800
+ const isStreaming = signal(false, ...(ngDevMode ? [{ debugName: "isStreaming" }] : /* istanbul ignore next */ []));
1801
+ const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
1802
+ let abortController = null;
1803
+ inject(DestroyRef).onDestroy(() => {
1804
+ abortController?.abort();
1805
+ });
1806
+ const clear = () => {
1807
+ messages.set([]);
1808
+ error.set(null);
1809
+ };
1810
+ const send = async (text) => {
1811
+ if (!text.trim())
1812
+ return;
1813
+ // Abort any existing request
1814
+ abortController?.abort();
1815
+ abortController = new AbortController();
1816
+ const userMessage = {
1817
+ id: generateChatId(),
1818
+ role: 'user',
1819
+ text: text.trim(),
1820
+ spec: null,
1821
+ };
1822
+ const assistantId = generateChatId();
1823
+ const assistantMessage = {
1824
+ id: assistantId,
1825
+ role: 'assistant',
1826
+ text: '',
1827
+ spec: null,
1828
+ };
1829
+ // Build messages array for the API (full conversation history + new message).
1830
+ const historyForApi = [
1831
+ ...messages()
1832
+ .filter((m) => m.id !== userMessage.id && m.id !== assistantId)
1833
+ .map((m) => ({
1834
+ role: m.role,
1835
+ content: m.text,
1836
+ })),
1837
+ { role: 'user', content: text.trim() },
1838
+ ];
1839
+ // Append user message and empty assistant placeholder
1840
+ messages.update((prev) => [...prev, userMessage, assistantMessage]);
1841
+ isStreaming.set(true);
1842
+ error.set(null);
1843
+ // Mutable state for accumulating the assistant response
1844
+ let accumulatedText = '';
1845
+ const currentSpec = { root: '', elements: {} };
1846
+ let hasSpec = false;
1847
+ const snapshotSpec = () => ({
1848
+ root: currentSpec.root,
1849
+ elements: { ...currentSpec.elements },
1850
+ ...(currentSpec.state ? { state: { ...currentSpec.state } } : {}),
1851
+ });
1852
+ try {
1853
+ const response = await fetch(options.api, {
1854
+ method: 'POST',
1855
+ headers: { 'Content-Type': 'application/json' },
1856
+ body: JSON.stringify({ messages: historyForApi }),
1857
+ signal: abortController.signal,
1858
+ });
1859
+ if (!response.ok) {
1860
+ let errorMessage = `HTTP error: ${response.status}`;
1861
+ try {
1862
+ const errorData = await response.json();
1863
+ if (errorData.message) {
1864
+ errorMessage = errorData.message;
1865
+ }
1866
+ else if (errorData.error) {
1867
+ errorMessage = errorData.error;
1868
+ }
1869
+ }
1870
+ catch {
1871
+ // Ignore JSON parsing errors
1872
+ }
1873
+ throw new Error(errorMessage);
1874
+ }
1875
+ const reader = response.body?.getReader();
1876
+ if (!reader) {
1877
+ throw new Error('No response body');
1878
+ }
1879
+ const decoder = new TextDecoder();
1880
+ // Use createMixedStreamParser to classify lines
1881
+ const parser = createMixedStreamParser({
1882
+ onPatch(patch) {
1883
+ hasSpec = true;
1884
+ applySpecPatch(currentSpec, patch);
1885
+ const snapshot = snapshotSpec();
1886
+ messages.update((prev) => prev.map((m) => m.id === assistantId ? { ...m, spec: snapshot } : m));
1887
+ },
1888
+ onText(line) {
1889
+ accumulatedText += (accumulatedText ? '\n' : '') + line;
1890
+ messages.update((prev) => prev.map((m) => m.id === assistantId ? { ...m, text: accumulatedText } : m));
1891
+ },
1892
+ });
1893
+ while (true) {
1894
+ const { done, value } = await reader.read();
1895
+ if (done)
1896
+ break;
1897
+ parser.push(decoder.decode(value, { stream: true }));
1898
+ }
1899
+ parser.flush();
1900
+ // Build final message for onComplete callback
1901
+ const finalMessage = {
1902
+ id: assistantId,
1903
+ role: 'assistant',
1904
+ text: accumulatedText,
1905
+ spec: hasSpec ? snapshotSpec() : null,
1906
+ };
1907
+ options.onComplete?.(finalMessage);
1908
+ }
1909
+ catch (err) {
1910
+ if (err.name === 'AbortError') {
1911
+ return;
1912
+ }
1913
+ const resolvedError = err instanceof Error ? err : new Error(String(err));
1914
+ error.set(resolvedError);
1915
+ // Remove empty assistant message on error
1916
+ messages.update((prev) => prev.filter((m) => m.id !== assistantId || m.text.length > 0));
1917
+ options.onError?.(resolvedError);
1918
+ }
1919
+ finally {
1920
+ isStreaming.set(false);
1921
+ }
1922
+ };
1923
+ return {
1924
+ messages: messages.asReadonly(),
1925
+ isStreaming: isStreaming.asReadonly(),
1926
+ error: error.asReadonly(),
1927
+ send,
1928
+ clear,
1929
+ };
1930
+ }
1931
+
1932
+ /**
1933
+ * The schema for ngx-json-render.
1934
+ *
1935
+ * Defines:
1936
+ * - Spec: A flat tree of elements with keys, types, props, and children references
1937
+ * - Catalog: Components with props schemas, and optional actions
1938
+ *
1939
+ * This is the same spec grammar the other json-render renderers use, so
1940
+ * catalogs and specs are portable across frameworks.
1941
+ */
1942
+ const schema = defineSchema((s) => ({
1943
+ // What the AI-generated SPEC looks like
1944
+ spec: s.object({
1945
+ /** Root element key */
1946
+ root: s.string(),
1947
+ /** Flat map of elements by key */
1948
+ elements: s.record(s.object({
1949
+ /** Component type from catalog */
1950
+ type: s.ref('catalog.components'),
1951
+ /** Component props */
1952
+ props: s.propsOf('catalog.components'),
1953
+ /** Child element keys (flat reference) */
1954
+ children: s.array(s.string()),
1955
+ /** Visibility condition */
1956
+ visible: { ...s.any(), ...s.optional() },
1957
+ /** Repeat children from a state array */
1958
+ repeat: { ...s.any(), ...s.optional() },
1959
+ })),
1960
+ }),
1961
+ // What the CATALOG must provide
1962
+ catalog: s.object({
1963
+ /** Component definitions */
1964
+ components: s.map({
1965
+ /** Zod schema for component props */
1966
+ props: s.zod(),
1967
+ /** Slots for this component. Use ['default'] for children, or named slots like ['header', 'footer'] */
1968
+ slots: s.array(s.string()),
1969
+ /** Description for AI generation hints */
1970
+ description: s.string(),
1971
+ /** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */
1972
+ example: s.any(),
1973
+ }),
1974
+ /** Action definitions (optional) */
1975
+ actions: s.map({
1976
+ /** Zod schema for action params */
1977
+ params: s.zod(),
1978
+ /** Description for AI generation hints */
1979
+ description: s.string(),
1980
+ }),
1981
+ }),
1982
+ }), {
1983
+ builtInActions: [
1984
+ {
1985
+ name: 'setState',
1986
+ description: 'Update a value in the state model at the given statePath. Params: { statePath: string, value: any }',
1987
+ },
1988
+ {
1989
+ name: 'pushState',
1990
+ description: 'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.',
1991
+ },
1992
+ {
1993
+ name: 'removeState',
1994
+ description: 'Remove an item from an array in state by index. Params: { statePath: string, index: number }',
1995
+ },
1996
+ {
1997
+ name: 'validateForm',
1998
+ description: 'Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.',
1999
+ },
2000
+ ],
2001
+ defaultRules: [
2002
+ // Element integrity
2003
+ "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
2004
+ 'SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.',
2005
+ 'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
2006
+ // Field placement
2007
+ 'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',
2008
+ 'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.',
2009
+ // State and data
2010
+ 'When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).',
2011
+ 'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. For a nested list stored on the enclosing item, use "repeat": { "statePath": { "$item": "comments" }, "key": "id" }. The $item statePath form is valid only inside another repeat. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
2012
+ // Design quality
2013
+ 'Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.',
2014
+ 'For data-rich UIs, use multi-column layout components if available. For forms and single-column content, use vertical layout components. ONLY use components from the AVAILABLE COMPONENTS list.',
2015
+ 'Always include realistic, professional-looking sample data. For blogs include 3-4 posts with varied titles, authors, dates, categories. For products include names, prices, images. Never leave data empty.',
2016
+ ],
2017
+ });
2018
+
2019
+ /*
2020
+ * Public API Surface of ngx-json-render
2021
+ */
2022
+ // Renderer components
2023
+
2024
+ /**
2025
+ * Generated bundle index. Do not edit.
2026
+ */
2027
+
2028
+ export { JrChildren, JrConfirmDialog, JrElement, JrRepeatScope, JsonRenderActionsService, JsonRenderRootContext, JsonRenderStateService, JsonRenderValidationService, JsonRenderer, RENDER_CONTEXT, REPEAT_SCOPE, applyPatch, buildSpecFromParts, createStoreSetState, defineRegistry, flatToTree, getTextFromParts, injectAction, injectActions, injectBoundProp, injectChatUI, injectDevtoolsActive, injectFieldValidation, injectRenderContext, injectRepeatScope, injectStateBinding, injectStateStore, injectStateValue, injectUIStream, injectValidation, jsonRenderMessage, schema };
2029
+ //# sourceMappingURL=ngx-json-render.mjs.map