ngx-json-render 0.7.7 → 0.8.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.
@@ -1,8 +1,9 @@
1
1
  import { NgComponentOutlet } from '@angular/common';
2
2
  import * as i0 from '@angular/core';
3
3
  import { signal, Injectable, isDevMode, inject, computed, untracked, effect, DestroyRef, InjectionToken, input, output, viewChild, afterNextRender, ChangeDetectionStrategy, Component, Injector, forwardRef } from '@angular/core';
4
- import { createStateStore, getByPath, runValidation, resolveAction, nextActionDispatchId, notifyActionDispatch, notifyActionSettle, executeAction, isDevtoolsActive, subscribeDevtoolsActive, resolveRepeatStatePath, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, autoFixSpec, validateSpec, createDirectiveRegistry, resolveRepeatItemStatePath, removeByPath, addByPath, setByPath, SPEC_DATA_PART_TYPE, nestedToFlat, createMixedStreamParser, defineSchema } from '@json-render/core';
4
+ import { createStateStore, getByPath, runValidation, resolveAction, nextActionDispatchId, notifyActionDispatch, notifyActionSettle, executeAction, isDevtoolsActive, subscribeDevtoolsActive, resolveRepeatStatePath, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, autoFixSpec, validateSpec, createDirectiveRegistry, resolveRepeatItemStatePath, removeByPath, addByPath, setByPath, SPEC_DATA_PART_TYPE, nestedToFlat, createMixedStreamParser } from '@json-render/core';
5
5
  export { autoFixSpec, createStateStore, formatSpecIssues, nestedToFlat, validateSpec } from '@json-render/core';
6
+ export { schema } from 'ngx-json-render/schema';
6
7
 
7
8
  const EMPTY = signal(undefined, ...(ngDevMode ? [{ debugName: "EMPTY" }] : /* istanbul ignore next */ []));
8
9
  /**
@@ -92,6 +93,37 @@ function isPlainObject(value) {
92
93
  Object.getPrototypeOf(value) === Object.prototype);
93
94
  }
94
95
 
96
+ /**
97
+ * Whether two values hold the same JSON content.
98
+ *
99
+ * For telling a value that arrived as a new object from one that changed — a
100
+ * spec element, or a leaf of `spec.state`, when the host hands over a spec
101
+ * built from fresh objects. Plain objects and arrays are compared by what
102
+ * they hold; anything else only by identity, which data parsed from JSON
103
+ * never needs. A container reached twice on the way down counts as
104
+ * different: JSON cannot refer to itself, and treating such a value as
105
+ * changed is what the renderer did before it compared anything.
106
+ */
107
+ function sameJson(a, b, seen = new Set()) {
108
+ if (Object.is(a, b))
109
+ return true;
110
+ if (!isPlainContainer(a) || !isPlainContainer(b))
111
+ return false;
112
+ if (Array.isArray(a) !== Array.isArray(b) || seen.has(a))
113
+ return false;
114
+ seen.add(a);
115
+ const keys = Object.keys(a);
116
+ if (keys.length !== Object.keys(b).length)
117
+ return false;
118
+ return keys.every((key) => Object.hasOwn(b, key) && sameJson(a[key], b[key], seen));
119
+ }
120
+ function isPlainContainer(value) {
121
+ if (typeof value !== 'object' || value === null)
122
+ return false;
123
+ const prototype = Object.getPrototypeOf(value);
124
+ return (Array.isArray(value) || prototype === Object.prototype || prototype === null);
125
+ }
126
+
95
127
  /**
96
128
  * The state store of a `<json-render>` subtree.
97
129
  *
@@ -151,7 +183,10 @@ class JsonRenderStateService {
151
183
  });
152
184
  // Uncontrolled mode: when the (resolved) initial state changes — e.g.
153
185
  // `spec.state` grows while streaming — diff it against the previous
154
- // initial state and apply only the changed leaves to the store.
186
+ // initial state and apply only the changed leaves to the store. Leaves
187
+ // compare by content: arrays are leaves, and a spec handed over again as
188
+ // new objects would otherwise write every array back, over whatever the
189
+ // user has since changed inside it.
155
190
  let prevFlat = {};
156
191
  effect(() => {
157
192
  if (this.root.store())
@@ -166,7 +201,7 @@ class JsonRenderStateService {
166
201
  ]);
167
202
  const updates = {};
168
203
  for (const key of allKeys) {
169
- if (prevFlat[key] !== nextFlat[key]) {
204
+ if (!sameJson(prevFlat[key], nextFlat[key])) {
170
205
  updates[key] = key in nextFlat ? nextFlat[key] : undefined;
171
206
  }
172
207
  }
@@ -1085,8 +1120,6 @@ function injectDevtoolsActive() {
1085
1120
  *
1086
1121
  * - a registered directive, whose `resolve` receives the whole context and may
1087
1122
  * read any path at all;
1088
- * - a `$bindState` or `$bindItem` prop — the renderer keeps two-way bound
1089
- * elements resolving on every write, see `JrElement.resolvedElement`;
1090
1123
  * - a `$`-prefixed key it doesn't recognise, which is what an expression added
1091
1124
  * to core after this was written would look like;
1092
1125
  * - a read of the whole state (`''` or `'/'`), and a condition it can't parse.
@@ -1123,10 +1156,13 @@ class StateReader {
1123
1156
  return this.item(value['$item']);
1124
1157
  if ('$index' in value && value['$index'] === true)
1125
1158
  return true;
1126
- if (typeof value['$bindState'] === 'string')
1127
- return false;
1128
- if (typeof value['$bindItem'] === 'string')
1129
- return false;
1159
+ // A two-way binding reads the path it writes back to.
1160
+ if (typeof value['$bindState'] === 'string') {
1161
+ return this.state(value['$bindState']);
1162
+ }
1163
+ if (typeof value['$bindItem'] === 'string') {
1164
+ return this.boundItem(value['$bindItem']);
1165
+ }
1130
1166
  if ('$cond' in value && '$then' in value && '$else' in value) {
1131
1167
  return (this.condition(value['$cond']) &&
1132
1168
  this.value(value['$then']) &&
@@ -1220,6 +1256,17 @@ class StateReader {
1220
1256
  this.paths.add(path);
1221
1257
  return true;
1222
1258
  }
1259
+ /**
1260
+ * A `$bindItem`, joined the way core joins it — which is not quite how it
1261
+ * joins an `$item` read. Outside a repeat it resolves to nothing.
1262
+ */
1263
+ boundItem(itemPath) {
1264
+ if (this.repeatBasePath === undefined)
1265
+ return true;
1266
+ return this.state(itemPath === ''
1267
+ ? this.repeatBasePath
1268
+ : `${this.repeatBasePath}/${itemPath}`);
1269
+ }
1223
1270
  /**
1224
1271
  * A read of the repeat item, as the state path it sits at. The item value
1225
1272
  * reaches the element on its own, but only as the same object when an
@@ -1422,6 +1469,15 @@ function sameContent(a, b, seen = new Set()) {
1422
1469
  return keys.every((key) => Object.hasOwn(b, key) &&
1423
1470
  sameContent(a[key], b[key], seen));
1424
1471
  }
1472
+ /**
1473
+ * Whether a state write at `written` can change the value at `bound`: the
1474
+ * same path, a container above it, or a path inside it.
1475
+ */
1476
+ function touches(written, bound) {
1477
+ return (written === bound ||
1478
+ bound.startsWith(`${written}/`) ||
1479
+ written.startsWith(`${bound}/`));
1480
+ }
1425
1481
  /** Element keys already warned about by the check on skipped writes. */
1426
1482
  const warnedSkippedWrites = new Set();
1427
1483
  const EMPTY_READS = new Set();
@@ -1458,8 +1514,17 @@ class JrElement {
1458
1514
  repeatScope = inject(REPEAT_SCOPE, { optional: true });
1459
1515
  parentPath = inject(RENDER_PATH, { optional: true });
1460
1516
  devtoolsActive = injectDevtoolsActive();
1461
- /** The raw (unresolved) element from the spec. */
1462
- rawElement = computed(() => this.root.spec()?.elements?.[this.elementKey()], ...(ngDevMode ? [{ debugName: "rawElement" }] : /* istanbul ignore next */ []));
1517
+ /**
1518
+ * The raw (unresolved) element from the spec.
1519
+ *
1520
+ * Compared by content, not identity. A streamed patch shares every element
1521
+ * it did not touch, and the identity check settles those at once; but a
1522
+ * spec can also arrive as all-new objects — a whole-spec part, a host that
1523
+ * fetches it again, `buildSpecFromParts` replaying a message — and then an
1524
+ * element whose content is unchanged would resolve, and every container
1525
+ * with a fresh `children` array re-render, for nothing.
1526
+ */
1527
+ rawElement = computed(() => this.root.spec()?.elements?.[this.elementKey()], { ...(ngDevMode ? { debugName: "rawElement" } : /* istanbul ignore next */ {}), equal: (a, b) => sameJson(a, b) });
1463
1528
  /**
1464
1529
  * The state paths this element's expressions can read, or null when the
1465
1530
  * spec doesn't say — see {@link collectStateReads} for when that is.
@@ -1543,40 +1608,55 @@ class JrElement {
1543
1608
  return evaluateVisibility(el.visible, this.resolutionCtx());
1544
1609
  }, ...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
1545
1610
  /**
1546
- * The element with all prop expressions resolved.
1611
+ * How many state writes have touched a path this element is two-way bound
1612
+ * to. See {@link resolution} for why those writes count on their own.
1613
+ */
1614
+ boundWrites = signal(0, ...(ngDevMode ? [{ debugName: "boundWrites" }] : /* istanbul ignore next */ []));
1615
+ /**
1616
+ * The element with all prop expressions resolved, and the bound writes it
1617
+ * was resolved after.
1547
1618
  *
1548
- * Resolving builds a new props object on every state write, whatever the
1549
- * write touched. Handing that on as a change would re-run every catalog
1550
- * template in the tree for one keystroke, so a result that holds the same
1551
- * values as the last one counts as unchanged and keeps the old object.
1619
+ * Resolving builds a new props object every time, so a result that holds
1620
+ * the same values as the last one counts as unchanged and keeps the old
1621
+ * object; otherwise one keystroke would re-run every catalog template that
1622
+ * happened to resolve.
1552
1623
  *
1553
- * Two-way bound elements are the exception, and keep treating every
1554
- * resolution as a change. Their component writes the user's input into the
1555
- * DOM before it reaches state, so the DOM can differ from a prop whose
1556
- * value never did: typing and a `clearStatePath` in the same turn take
1557
- * the prop from '' through 'milk' back to '' with nobody reading 'milk'.
1558
- * The README tells catalog authors to sync such inputs with an effect on
1559
- * props, and that effect only gets to put the DOM right if it runs.
1624
+ * A write to a path the element is bound to always counts, though. Its
1625
+ * component writes the user's input into the DOM before it reaches state,
1626
+ * so the DOM can differ from a prop whose value never did: typing and a
1627
+ * `clearStatePath` in the same turn take the prop from '' through 'milk'
1628
+ * back to '' with nobody reading 'milk'. The README tells catalog authors
1629
+ * to sync such inputs with an effect on props, and that effect only gets to
1630
+ * put the DOM right if it runs. Writes anywhere else compare by value, like
1631
+ * any element's.
1560
1632
  */
1561
- resolvedElement = computed(() => {
1633
+ resolution = computed(() => {
1634
+ const boundWrites = this.boundWrites();
1562
1635
  const el = this.rawElement();
1563
1636
  if (!el)
1564
- return undefined;
1637
+ return { element: undefined, boundWrites };
1565
1638
  return {
1566
- ...el,
1567
- props: resolveElementProps(el.props ?? {}, this.resolutionCtx()),
1639
+ element: {
1640
+ ...el,
1641
+ props: resolveElementProps(el.props ?? {}, this.resolutionCtx()),
1642
+ },
1643
+ boundWrites,
1568
1644
  };
1569
- }, { ...(ngDevMode ? { debugName: "resolvedElement" } : /* istanbul ignore next */ {}), equal: (a, b) => {
1570
- if (a === b)
1645
+ }, { ...(ngDevMode ? { debugName: "resolution" } : /* istanbul ignore next */ {}), equal: (a, b) => {
1646
+ if (a.boundWrites !== b.boundWrites)
1647
+ return false;
1648
+ if (a.element === b.element)
1571
1649
  return true;
1572
- if (!a || !b || untracked(this.bindings))
1650
+ if (!a.element || !b.element)
1573
1651
  return false;
1574
- const { props: aProps, ...aRest } = a;
1575
- const { props: bProps, ...bRest } = b;
1652
+ const { props: aProps, ...aRest } = a.element;
1653
+ const { props: bProps, ...bRest } = b.element;
1576
1654
  // Everything but props is the spec element itself, never state.
1577
1655
  return (sameValues(aRest, bRest, true) &&
1578
1656
  sameValues(aProps, bProps, !untracked(this.root.store)));
1579
1657
  } });
1658
+ /** The element with all prop expressions resolved. */
1659
+ resolvedElement = computed(() => this.resolution().element, ...(ngDevMode ? [{ debugName: "resolvedElement" }] : /* istanbul ignore next */ []));
1580
1660
  /** Two-way binding paths ($bindState / $bindItem) by prop name. */
1581
1661
  bindings = computed(() => {
1582
1662
  const el = this.rawElement();
@@ -1687,6 +1767,21 @@ class JrElement {
1687
1767
  });
1688
1768
  });
1689
1769
  }
1770
+ // Count the writes that touch a path this element is bound to. Only an
1771
+ // element with bindings listens, and it reads them when a write arrives,
1772
+ // so a binding that moves (a repeat item that shifts) is followed without
1773
+ // subscribing again.
1774
+ effect((onCleanup) => {
1775
+ if (!this.bindings())
1776
+ return;
1777
+ const unsubscribe = this.state.subscribeChanges((changes) => {
1778
+ const paths = Object.values(untracked(this.bindings) ?? {});
1779
+ if (changes.some((change) => paths.some((path) => touches(change.path, path)))) {
1780
+ this.boundWrites.update((count) => count + 1);
1781
+ }
1782
+ });
1783
+ onCleanup(unsubscribe);
1784
+ });
1690
1785
  // Warn (once per type) about unknown component types.
1691
1786
  const warnedTypes = new Set();
1692
1787
  effect(() => {
@@ -3622,97 +3717,6 @@ function injectChatUI(options) {
3622
3717
  * - `ui-stream` / `chat-ui` — the two hooks, composed from the above.
3623
3718
  */
3624
3719
 
3625
- /**
3626
- * The schema for ngx-json-render.
3627
- *
3628
- * Defines:
3629
- * - Spec: A flat tree of elements with keys, types, props, and children references
3630
- * - Catalog: Components with props schemas, and optional actions
3631
- *
3632
- * This is the same spec grammar the other json-render renderers use, so
3633
- * catalogs and specs are portable across frameworks.
3634
- */
3635
- const schema = defineSchema((s) => ({
3636
- // What the AI-generated SPEC looks like
3637
- spec: s.object({
3638
- /** Root element key */
3639
- root: s.string(),
3640
- /** Flat map of elements by key */
3641
- elements: s.record(s.object({
3642
- /** Component type from catalog */
3643
- type: s.ref('catalog.components'),
3644
- /** Component props */
3645
- props: s.propsOf('catalog.components'),
3646
- /** Child element keys (flat reference) */
3647
- children: s.array(s.string()),
3648
- /** Visibility condition */
3649
- visible: { ...s.any(), ...s.optional() },
3650
- /** Repeat children from a state array */
3651
- repeat: { ...s.any(), ...s.optional() },
3652
- })),
3653
- }),
3654
- // What the CATALOG must provide
3655
- catalog: s.object({
3656
- /** Component definitions */
3657
- components: s.map({
3658
- /** Zod schema for component props */
3659
- props: s.zod(),
3660
- /** Slots for this component. Use ['default'] for children, or named slots like ['header', 'footer'] */
3661
- slots: s.array(s.string()),
3662
- /** Description for AI generation hints */
3663
- description: s.string(),
3664
- /** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */
3665
- example: s.any(),
3666
- }),
3667
- /** Action definitions (optional) */
3668
- actions: s.map({
3669
- /** Zod schema for action params */
3670
- params: s.zod(),
3671
- /** Description for AI generation hints */
3672
- description: s.string(),
3673
- }),
3674
- }),
3675
- }), {
3676
- builtInActions: [
3677
- {
3678
- name: 'setState',
3679
- description: 'Update a value in the state model at the given statePath. Params: { statePath: string, value: any }',
3680
- },
3681
- {
3682
- name: 'pushState',
3683
- 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.',
3684
- },
3685
- {
3686
- name: 'removeState',
3687
- description: 'Remove an item from an array in state by index. Params: { statePath: string, index: number }',
3688
- },
3689
- {
3690
- name: 'validateForm',
3691
- 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[]> }.',
3692
- },
3693
- {
3694
- name: 'submitForm',
3695
- description: "Validate all registered form fields and, only if every one passes, dispatch another action. Params: { action: string, params?: object, statePath?: string }. Writes the same { valid, errors } result as validateForm. Use this on a form's submit button: it is validateForm and the submit in one binding, and the submit is skipped when the form is invalid.",
3696
- },
3697
- ],
3698
- defaultRules: [
3699
- // Element integrity
3700
- "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.",
3701
- '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.',
3702
- 'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
3703
- // Field placement
3704
- 'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',
3705
- '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.',
3706
- // State and data
3707
- '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).',
3708
- '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.',
3709
- // Design quality
3710
- '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.',
3711
- '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.',
3712
- '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.',
3713
- ],
3714
- });
3715
-
3716
3720
  /*
3717
3721
  * Public API Surface of ngx-json-render
3718
3722
  */
@@ -3722,5 +3726,5 @@ const schema = defineSchema((s) => ({
3722
3726
  * Generated bundle index. Do not edit.
3723
3727
  */
3724
3728
 
3725
- export { CONFIRM_CONTEXT, ELEMENT_KEY, JR_CONFIRM_DIALOG, JR_CONFIRM_LABELS, JrChildren, JrConfirmDialog, JrElement, JrRepeatScope, JsonRenderActionsService, JsonRenderRootContext, JsonRenderStateService, JsonRenderValidationService, JsonRenderer, RENDER_CONTEXT, REPEAT_SCOPE, applyPatch, buildSpecFromParts, checkSpec, createStoreSetState, defineRegistry, flatToTree, formatSpecCheckIssues, getTextFromParts, injectAction, injectActions, injectBoundProp, injectChatUI, injectConfirmContext, injectDevtoolsActive, injectElementKey, injectFieldValidation, injectRenderContext, injectRepeatScope, injectStateBinding, injectStateStore, injectStateValue, injectUIStream, injectValidation, isActionCancelled, jsonRenderMessage, schema };
3729
+ export { CONFIRM_CONTEXT, ELEMENT_KEY, JR_CONFIRM_DIALOG, JR_CONFIRM_LABELS, JrChildren, JrConfirmDialog, JrElement, JrRepeatScope, JsonRenderActionsService, JsonRenderRootContext, JsonRenderStateService, JsonRenderValidationService, JsonRenderer, RENDER_CONTEXT, REPEAT_SCOPE, applyPatch, buildSpecFromParts, checkSpec, createStoreSetState, defineRegistry, flatToTree, formatSpecCheckIssues, getTextFromParts, injectAction, injectActions, injectBoundProp, injectChatUI, injectConfirmContext, injectDevtoolsActive, injectElementKey, injectFieldValidation, injectRenderContext, injectRepeatScope, injectStateBinding, injectStateStore, injectStateValue, injectUIStream, injectValidation, isActionCancelled, jsonRenderMessage };
3726
3730
  //# sourceMappingURL=ngx-json-render.mjs.map