ngx-json-render 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -239,7 +239,7 @@ app.post('/api/generate', async (req, res) => {
239
239
  });
240
240
  ```
241
241
 
242
- The patches apply to the `spec` signal as each line arrives, so the UI assembles on screen while the model is still generating — exactly what the [demo's Streaming tab](https://shteynu.github.io/ngx-json-render/) replays. Prefer structured output? `catalog.jsonSchema()` exports a JSON Schema for `streamObject`/tool calls, and `catalog.validate(spec)` checks a finished spec against the catalog.
242
+ The patches apply to the `spec` signal as each line arrives, so the UI assembles on screen while the model is still generating — exactly what the [demo's Streaming tab](https://shteynu.github.io/ngx-json-render/) replays. Prefer structured output? `catalog.jsonSchema()` exports a JSON Schema for `streamObject`/tool calls, and `checkSpec(spec, 'strict', { catalog })` checks a finished spec against the catalog, props included — see [Checking what the model produced](#checking-what-the-model-produced) for why that and not `catalog.validate(spec)` alone.
243
243
 
244
244
  ### Chat + GenUI
245
245
 
@@ -372,12 +372,23 @@ is a patch that has not arrived yet, not a defect, so nothing is reported and
372
372
  `strict` keeps rendering; the hooks check once, when the generation completes.
373
373
 
374
374
  Pass the catalog too, and the same check covers what structure alone cannot
375
- see — every `type` is one the catalog defines, and the props match its schema:
375
+ see — every `type` is one the catalog defines, and each element's props match
376
+ its component's schema:
376
377
 
377
378
  ```html
378
379
  <json-render [spec]="spec()" [registry]="registry" [catalog]="catalog" validate="strict" />
379
380
  ```
380
381
 
382
+ A prop written as an expression — `{"$state": …}`, `{"$bindState": …}`,
383
+ `{"$template": …}`, a directive — is not checked: it has no value until render
384
+ time. The catalog's spec schema also holds every element to the grammar, which
385
+ wants a `children` array even on a leaf.
386
+
387
+ The props check is this package's own. Core's `catalog.validate(spec)` gives an
388
+ element's props its component's schema only when the catalog has exactly one
389
+ component; with more, it checks element shapes and `type` names but not a
390
+ single prop.
391
+
381
392
  ### Capping what a spec may cost
382
393
 
383
394
  `validate` describes a spec. `renderLimits` constrains it:
@@ -754,10 +765,13 @@ not generate should set all three. See
754
765
  [Capping what a spec may cost](#capping-what-a-spec-may-cost).
755
766
 
756
767
  **A spec can name components you never built.** `validate` with a `catalog`
757
- reports every `type` the catalog does not define and every prop its schema
758
- rejects; `strict` refuses such a spec outright. Without a catalog the renderer
759
- only warns and draws nothing in that element's place, which degrades well but
760
- tells you nothing until you read the console.
768
+ reports every `type` the catalog does not define and every prop its component's
769
+ schema rejects; `strict` refuses such a spec outright. A prop bound to an
770
+ expression (`$state`, `$bindState`, `$template`, …) is not among them — its value
771
+ only exists at render time — so a component should not count on such a prop
772
+ having the type its schema declares. Without a catalog the renderer only warns
773
+ and draws nothing in that element's place, which degrades well but tells you
774
+ nothing until you read the console.
761
775
 
762
776
  **`confirm` is a UX affordance, not a security control.** It routes an action
763
777
  through the confirmation dialog before the handler runs, but it is set on the
@@ -1898,63 +1898,219 @@ function depthIssue(depth, limit) {
1898
1898
  };
1899
1899
  }
1900
1900
  /**
1901
- * Check a spec against a catalog: the component types it names, and then the
1902
- * catalog's own schema.
1901
+ * Check a spec against a catalog: the component types it names, then the
1902
+ * catalog's spec schema, then each element's props against its own
1903
+ * component's schema.
1903
1904
  *
1904
- * The schema pass is skipped while a type is unknown. A catalog schema keys
1905
- * its element shapes off `type`, so one name it does not recognise turns into
1906
- * a pile of prop errors describing a component that was never the point.
1905
+ * The props need that pass of their own. Core builds one schema for the whole
1906
+ * spec, and it gives an element's props a component's schema only when the
1907
+ * catalog has exactly one component; with more, every element's props are an
1908
+ * open record, whatever its `type`. So `catalog.validate` holds an element to
1909
+ * its shape — `children`, a `type` from the catalog — and not to its props.
1910
+ *
1911
+ * Whatever that schema did say about the props of an element checked here is
1912
+ * dropped: with a one-component catalog it would be reported twice, and it
1913
+ * judges expressions this pass knows to leave alone.
1914
+ *
1915
+ * The schema passes are skipped while a type is unknown. That is an error on
1916
+ * its own, and the spec schema would only restate it as a `type` outside the
1917
+ * ones it accepts.
1907
1918
  */
1908
1919
  function catalogIssues(spec, catalog) {
1909
- const issues = [];
1920
+ const elements = Object.entries(spec.elements ?? {});
1910
1921
  const known = new Set(catalog.componentNames);
1911
- for (const [key, element] of Object.entries(spec.elements ?? {})) {
1922
+ const unknown = [];
1923
+ for (const [key, element] of elements) {
1912
1924
  if (known.has(element.type))
1913
1925
  continue;
1914
- issues.push({
1926
+ unknown.push({
1915
1927
  severity: 'error',
1916
1928
  code: 'unknown_component',
1917
1929
  elementKey: key,
1918
1930
  message: `Element "${key}" has type "${element.type}", which this catalog does not define. Nothing renders for it.`,
1919
1931
  });
1920
1932
  }
1921
- if (issues.length > 0)
1922
- return issues;
1923
- const result = catalog.validate(spec);
1933
+ if (unknown.length > 0)
1934
+ return unknown;
1935
+ const schemas = new Map();
1936
+ for (const [key, element] of elements) {
1937
+ const schema = propsSchemaOf(catalog.data, element.type);
1938
+ if (schema)
1939
+ schemas.set(key, schema);
1940
+ }
1941
+ const found = specSchemaIssues(spec, catalog).filter(({ path }) => !(path[0] === 'elements' &&
1942
+ typeof path[1] === 'string' &&
1943
+ schemas.has(path[1]) &&
1944
+ path[2] === 'props'));
1945
+ for (const [key, element] of elements) {
1946
+ const room = MAX_REPORTED_PROP_ISSUES - found.length;
1947
+ if (room <= 0)
1948
+ break;
1949
+ const schema = schemas.get(key);
1950
+ if (schema)
1951
+ found.push(...propsIssues(key, element, schema, room));
1952
+ }
1953
+ return found.slice(0, MAX_REPORTED_PROP_ISSUES).map(toSpecCheckIssue);
1954
+ }
1955
+ /**
1956
+ * The props schema a catalog declares for a component type, if it declares
1957
+ * one that can be called.
1958
+ */
1959
+ function propsSchemaOf(data, type) {
1960
+ const components = data
1961
+ ?.components;
1962
+ if (typeof components !== 'object' ||
1963
+ components === null ||
1964
+ !Object.hasOwn(components, type)) {
1965
+ return undefined;
1966
+ }
1967
+ const props = components[type]?.props;
1968
+ return typeof props
1969
+ ?.safeParse === 'function'
1970
+ ? props
1971
+ : undefined;
1972
+ }
1973
+ /** What the catalog's spec schema reported, with paths rooted at the spec. */
1974
+ function specSchemaIssues(spec, catalog) {
1975
+ try {
1976
+ const result = catalog.validate(spec);
1977
+ return result.success ? [] : readIssues(result.error);
1978
+ }
1979
+ catch (error) {
1980
+ return [{ path: [], message: threwMessage('the catalog schema', error) }];
1981
+ }
1982
+ }
1983
+ /**
1984
+ * What an element's component schema says about its props, leaving out what
1985
+ * it says about expressions, with paths rooted at the spec.
1986
+ *
1987
+ * A schema that throws counts as a rejection, not a crash. Real ones do:
1988
+ * `ValidationConfigSchema`, which every Material input's `validation` prop
1989
+ * uses, recurses through `enabled` and overflows the stack a thousand
1990
+ * `$and`s deep.
1991
+ */
1992
+ function propsIssues(key, element, schema, room) {
1993
+ const at = ['elements', key, 'props'];
1994
+ let result;
1995
+ try {
1996
+ result = schema.safeParse(element.props);
1997
+ }
1998
+ catch (error) {
1999
+ return [
2000
+ {
2001
+ path: at,
2002
+ message: threwMessage(`the "${element.type}" schema`, error),
2003
+ },
2004
+ ];
2005
+ }
1924
2006
  if (result.success)
1925
- return issues;
1926
- return propIssues(result.error);
2007
+ return [];
2008
+ const issues = [];
2009
+ for (const { path, message } of readIssues(result.error)) {
2010
+ if (issues.length >= room)
2011
+ break;
2012
+ if (reachesExpression(element.props, path))
2013
+ continue;
2014
+ issues.push({ path: [...at, ...path], message });
2015
+ }
2016
+ return issues;
2017
+ }
2018
+ /**
2019
+ * Whether a props issue is about an expression: it sits on one, inside one,
2020
+ * or on a value holding one.
2021
+ *
2022
+ * `{"$state": "/title"}` where the schema wants a string is not a defect.
2023
+ * Core resolves every such value before the component sees it, so the schema
2024
+ * would be judging something that never renders. Any `$`-prefixed key counts
2025
+ * — directive names must start with `$`, and so would an expression added to
2026
+ * core later. A value merely holding one is left alone too, since a union or
2027
+ * a refinement over it ran on the unresolved value.
2028
+ *
2029
+ * The props object itself is not an expression: core resolves the values
2030
+ * inside it, never the object.
2031
+ */
2032
+ function reachesExpression(props, path) {
2033
+ let value = props;
2034
+ for (const segment of path) {
2035
+ if (typeof value !== 'object' || value === null)
2036
+ return false;
2037
+ value = value[segment];
2038
+ if (isExpression(value))
2039
+ return true;
2040
+ }
2041
+ return holdsExpression(value);
2042
+ }
2043
+ /** A value core resolves at render time — or will, once it knows the key. */
2044
+ function isExpression(value) {
2045
+ return (typeof value === 'object' &&
2046
+ value !== null &&
2047
+ !Array.isArray(value) &&
2048
+ Object.keys(value).some((key) => key.startsWith('$')));
1927
2049
  }
1928
2050
  /**
1929
- * Turn whatever the catalog's schema reported into issues.
2051
+ * Whether anything nested inside `value` is an expression.
2052
+ *
2053
+ * Iterative, and it remembers what it has seen, for the same reason as
2054
+ * {@link analyseSpecGraph}: props are as attacker-shaped as the rest of the
2055
+ * spec, and a check that overflowed or looped on them would be a second way
2056
+ * to crash.
2057
+ */
2058
+ function holdsExpression(value) {
2059
+ const pending = [value];
2060
+ const seen = new Set();
2061
+ while (pending.length > 0) {
2062
+ const current = pending.pop();
2063
+ if (typeof current !== 'object' || current === null)
2064
+ continue;
2065
+ if (seen.has(current))
2066
+ continue;
2067
+ seen.add(current);
2068
+ for (const child of Object.values(current)) {
2069
+ if (isExpression(child))
2070
+ return true;
2071
+ pending.push(child);
2072
+ }
2073
+ }
2074
+ return false;
2075
+ }
2076
+ /**
2077
+ * Read what a schema reported.
1930
2078
  *
1931
2079
  * Read defensively rather than typed against the schema library: the shape
1932
2080
  * below (`issues`, each with `path` and `message`) is what every version of
1933
2081
  * it has produced, and a renderer that threw while explaining a bad spec
1934
2082
  * would be the worse failure.
1935
2083
  */
1936
- function propIssues(error) {
2084
+ function readIssues(error) {
1937
2085
  const raw = error?.issues;
1938
2086
  if (!Array.isArray(raw))
1939
2087
  return [];
1940
- return raw.slice(0, MAX_REPORTED_PROP_ISSUES).map((entry) => {
2088
+ return raw.map((entry) => {
1941
2089
  const item = entry;
1942
- const path = Array.isArray(item.path) ? item.path : [];
1943
- const message = typeof item.message === 'string' ? item.message : 'is not valid';
1944
- // Paths are rooted at the spec, so `elements.<key>.props.<prop>` names the
1945
- // element in its second segment.
1946
- const elementKey = path[0] === 'elements' && typeof path[1] === 'string'
1947
- ? path[1]
1948
- : undefined;
1949
- const where = path.length > 0 ? path.join('.') : 'spec';
2090
+ const path = item?.path;
2091
+ const message = item?.message;
1950
2092
  return {
1951
- severity: 'error',
1952
- code: 'invalid_props',
1953
- ...(elementKey === undefined ? {} : { elementKey }),
1954
- message: `${where}: ${message}`,
2093
+ path: Array.isArray(path) ? path : [],
2094
+ message: typeof message === 'string' ? message : 'is not valid',
1955
2095
  };
1956
2096
  });
1957
2097
  }
2098
+ function threwMessage(schema, error) {
2099
+ const reason = error instanceof Error ? ` (${error.name}: ${error.message})` : '';
2100
+ return `${schema} threw instead of answering${reason}, so this counts as rejected`;
2101
+ }
2102
+ function toSpecCheckIssue({ path, message }) {
2103
+ // Paths are rooted at the spec, so `elements.<key>.props.<prop>` names the
2104
+ // element in its second segment.
2105
+ const elementKey = path[0] === 'elements' && typeof path[1] === 'string' ? path[1] : undefined;
2106
+ const where = path.length > 0 ? path.map(String).join('.') : 'spec';
2107
+ return {
2108
+ severity: 'error',
2109
+ code: 'invalid_props',
2110
+ ...(elementKey === undefined ? {} : { elementKey }),
2111
+ message: `${where}: ${message}`,
2112
+ };
2113
+ }
1958
2114
 
1959
2115
  const CLEAN = {
1960
2116
  spec: null,
@@ -2139,7 +2295,11 @@ class JsonRenderer {
2139
2295
  /**
2140
2296
  * The catalog the spec was generated for. Given one, `validate` also checks
2141
2297
  * what structure alone cannot: that every `type` is a component the catalog
2142
- * defines, and that the props match its schema.
2298
+ * defines, that every element has the shape the catalog's spec schema asks
2299
+ * for — a `children` array, even on a leaf — and that its props match its
2300
+ * component's schema. A prop written as an expression — `$state`,
2301
+ * `$bindState`, `$template`, a directive — has no value until render time,
2302
+ * so it is not checked.
2143
2303
  *
2144
2304
  * Only read when `validate` is on, and — like the rest of the check —
2145
2305
  * skipped while `loading`.