ngx-json-render 0.5.1 → 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 +26 -6
- package/fesm2022/ngx-json-render.mjs +516 -31
- package/fesm2022/ngx-json-render.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-json-render.d.ts +68 -9
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NgComponentOutlet } from '@angular/common';
|
|
2
2
|
import * as i0 from '@angular/core';
|
|
3
3
|
import { signal, Injectable, inject, computed, untracked, effect, isDevMode, 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, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, autoFixSpec, validateSpec, createDirectiveRegistry,
|
|
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';
|
|
5
5
|
export { autoFixSpec, createStateStore, formatSpecIssues, nestedToFlat, validateSpec } from '@json-render/core';
|
|
6
6
|
import { flattenToPointers } from '@json-render/core/store-utils';
|
|
7
7
|
|
|
@@ -967,6 +967,169 @@ function injectDevtoolsActive() {
|
|
|
967
967
|
return active.asReadonly();
|
|
968
968
|
}
|
|
969
969
|
|
|
970
|
+
/**
|
|
971
|
+
* The state paths an element's expressions can read, or `null` when that
|
|
972
|
+
* can't be known from the spec.
|
|
973
|
+
*
|
|
974
|
+
* Worked out from the spec alone, before anything resolves, so that an element
|
|
975
|
+
* can depend on those paths rather than on the whole state. The answer may
|
|
976
|
+
* name more paths than a given resolution reads — both branches of a `$cond`,
|
|
977
|
+
* say — but never fewer: a path left out would be a write the element misses,
|
|
978
|
+
* and it would keep showing the old value with nothing in the console.
|
|
979
|
+
*
|
|
980
|
+
* `null` is the safe answer, and it is given whenever the spec holds something
|
|
981
|
+
* this reader does not model:
|
|
982
|
+
*
|
|
983
|
+
* - a registered directive, whose `resolve` receives the whole context and may
|
|
984
|
+
* read any path at all;
|
|
985
|
+
* - a `$bindState` or `$bindItem` prop — the renderer keeps two-way bound
|
|
986
|
+
* elements resolving on every write, see `JrElement.resolvedElement`;
|
|
987
|
+
* - a `$`-prefixed key it doesn't recognise, which is what an expression added
|
|
988
|
+
* to core after this was written would look like;
|
|
989
|
+
* - a read of the whole state (`''` or `'/'`), and a condition it can't parse.
|
|
990
|
+
*
|
|
991
|
+
* The walk mirrors core's `resolvePropValue` and `evaluateVisibility`, in the
|
|
992
|
+
* same order, because the order decides which expression an object is.
|
|
993
|
+
*/
|
|
994
|
+
function collectStateReads(element, directives, repeatBasePath) {
|
|
995
|
+
const reader = new StateReader(directives, repeatBasePath);
|
|
996
|
+
const understood = reader.condition(element.visible) &&
|
|
997
|
+
Object.values(element.props ?? {}).every((value) => reader.value(value));
|
|
998
|
+
return understood ? reader.paths : null;
|
|
999
|
+
}
|
|
1000
|
+
function isObject(value) {
|
|
1001
|
+
return typeof value === 'object' && value !== null;
|
|
1002
|
+
}
|
|
1003
|
+
/** Comparison operators a single condition may carry, as core reads them. */
|
|
1004
|
+
const COMPARISONS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'];
|
|
1005
|
+
class StateReader {
|
|
1006
|
+
directives;
|
|
1007
|
+
repeatBasePath;
|
|
1008
|
+
paths = new Set();
|
|
1009
|
+
constructor(directives, repeatBasePath) {
|
|
1010
|
+
this.directives = directives;
|
|
1011
|
+
this.repeatBasePath = repeatBasePath;
|
|
1012
|
+
}
|
|
1013
|
+
/** A prop value. Returns false when it can read state this can't name. */
|
|
1014
|
+
value(value) {
|
|
1015
|
+
if (!isObject(value))
|
|
1016
|
+
return true;
|
|
1017
|
+
if (typeof value['$state'] === 'string')
|
|
1018
|
+
return this.state(value['$state']);
|
|
1019
|
+
if (typeof value['$item'] === 'string')
|
|
1020
|
+
return this.item(value['$item']);
|
|
1021
|
+
if ('$index' in value && value['$index'] === true)
|
|
1022
|
+
return true;
|
|
1023
|
+
if (typeof value['$bindState'] === 'string')
|
|
1024
|
+
return false;
|
|
1025
|
+
if (typeof value['$bindItem'] === 'string')
|
|
1026
|
+
return false;
|
|
1027
|
+
if ('$cond' in value && '$then' in value && '$else' in value) {
|
|
1028
|
+
return (this.condition(value['$cond']) &&
|
|
1029
|
+
this.value(value['$then']) &&
|
|
1030
|
+
this.value(value['$else']));
|
|
1031
|
+
}
|
|
1032
|
+
if (typeof value['$computed'] === 'string') {
|
|
1033
|
+
// The function is handed its resolved arguments and nothing else.
|
|
1034
|
+
const args = value['args'];
|
|
1035
|
+
return (!args || Object.values(Object(args)).every((arg) => this.value(arg)));
|
|
1036
|
+
}
|
|
1037
|
+
if (typeof value['$template'] === 'string') {
|
|
1038
|
+
return this.template(value['$template']);
|
|
1039
|
+
}
|
|
1040
|
+
if (Array.isArray(value))
|
|
1041
|
+
return value.every((item) => this.value(item));
|
|
1042
|
+
if (this.directives) {
|
|
1043
|
+
for (const name of this.directives.keys()) {
|
|
1044
|
+
if (name in value)
|
|
1045
|
+
return false;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1049
|
+
if (key.startsWith('$') || !this.value(entry))
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
return true;
|
|
1053
|
+
}
|
|
1054
|
+
/** A `visible` condition, or the condition of a `$cond`. */
|
|
1055
|
+
condition(condition) {
|
|
1056
|
+
if (condition === undefined || typeof condition === 'boolean')
|
|
1057
|
+
return true;
|
|
1058
|
+
if (Array.isArray(condition)) {
|
|
1059
|
+
return condition.every((single) => this.singleCondition(single));
|
|
1060
|
+
}
|
|
1061
|
+
if (!isObject(condition))
|
|
1062
|
+
return false;
|
|
1063
|
+
for (const group of ['$and', '$or']) {
|
|
1064
|
+
if (group in condition) {
|
|
1065
|
+
const children = condition[group];
|
|
1066
|
+
return (Array.isArray(children) &&
|
|
1067
|
+
children.every((child) => this.condition(child)));
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return this.singleCondition(condition);
|
|
1071
|
+
}
|
|
1072
|
+
singleCondition(condition) {
|
|
1073
|
+
if (!isObject(condition))
|
|
1074
|
+
return false;
|
|
1075
|
+
let understood;
|
|
1076
|
+
if ('$index' in condition) {
|
|
1077
|
+
understood = true;
|
|
1078
|
+
}
|
|
1079
|
+
else if ('$item' in condition) {
|
|
1080
|
+
const item = condition['$item'];
|
|
1081
|
+
understood = typeof item === 'string' && this.item(item);
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
// Core reads `$state` here without checking it: anything but a string
|
|
1085
|
+
// path reads the whole state.
|
|
1086
|
+
const path = condition['$state'];
|
|
1087
|
+
understood = typeof path === 'string' && this.state(path);
|
|
1088
|
+
}
|
|
1089
|
+
// Core looks only at the first operator present. Reading all of them is
|
|
1090
|
+
// a superset of that.
|
|
1091
|
+
for (const operator of COMPARISONS) {
|
|
1092
|
+
const operand = condition[operator];
|
|
1093
|
+
if (isObject(operand) && typeof operand['$state'] === 'string') {
|
|
1094
|
+
understood = this.state(operand['$state']) && understood;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return understood;
|
|
1098
|
+
}
|
|
1099
|
+
template(template) {
|
|
1100
|
+
for (const [, placeholder] of template.matchAll(/\$\{([^}]+)\}/g)) {
|
|
1101
|
+
if (placeholder.startsWith('/')) {
|
|
1102
|
+
if (!this.state(placeholder))
|
|
1103
|
+
return false;
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
// Core tries the repeat item first and falls back to a state path of
|
|
1107
|
+
// the same name when the item has nothing there.
|
|
1108
|
+
if (!this.item(placeholder) || !this.state(`/${placeholder}`)) {
|
|
1109
|
+
return false;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return true;
|
|
1113
|
+
}
|
|
1114
|
+
state(path) {
|
|
1115
|
+
if (path === '' || path === '/')
|
|
1116
|
+
return false;
|
|
1117
|
+
this.paths.add(path);
|
|
1118
|
+
return true;
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* A read of the repeat item, as the state path it sits at. The item value
|
|
1122
|
+
* reaches the element on its own, but only as the same object when an
|
|
1123
|
+
* external store edits it in place, so the path is what notices the edit.
|
|
1124
|
+
*/
|
|
1125
|
+
item(itemPath) {
|
|
1126
|
+
if (this.repeatBasePath === undefined)
|
|
1127
|
+
return true;
|
|
1128
|
+
const path = resolveRepeatStatePath({ $item: itemPath }, this.repeatBasePath);
|
|
1129
|
+
return path !== undefined && this.state(path);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
970
1133
|
/**
|
|
971
1134
|
* The render context of the element currently being rendered.
|
|
972
1135
|
* Provided by the renderer for every catalog component instance.
|
|
@@ -992,6 +1155,15 @@ const REPEAT_SCOPE = new InjectionToken('ngx-json-render REPEAT_SCOPE');
|
|
|
992
1155
|
* `renderLimits.maxDepth`.
|
|
993
1156
|
*/
|
|
994
1157
|
const RENDER_PATH = new InjectionToken('ngx-json-render RENDER_PATH');
|
|
1158
|
+
/**
|
|
1159
|
+
* Whether an element that skipped a state write checks that it was right to:
|
|
1160
|
+
* that resolving it against the whole state would not give anything else.
|
|
1161
|
+
* On in dev mode, off in production.
|
|
1162
|
+
*
|
|
1163
|
+
* @internal Not part of the public API. The check calls `$computed`
|
|
1164
|
+
* functions again, so tests that count those calls turn it off.
|
|
1165
|
+
*/
|
|
1166
|
+
const CHECK_SKIPPED_WRITES = new InjectionToken('ngx-json-render CHECK_SKIPPED_WRITES', { providedIn: 'root', factory: () => isDevMode() });
|
|
995
1167
|
/**
|
|
996
1168
|
* Inject the render context inside a catalog component.
|
|
997
1169
|
*
|
|
@@ -1117,6 +1289,49 @@ function sameValues(a, b, objectsByIdentity) {
|
|
|
1117
1289
|
}
|
|
1118
1290
|
return true;
|
|
1119
1291
|
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Whether two resolved values hold the same content, however they are built.
|
|
1294
|
+
*
|
|
1295
|
+
* For the dev-mode check on skipped writes, which compares a fresh resolution
|
|
1296
|
+
* with an old one: a `$computed` function returns a new object every time,
|
|
1297
|
+
* so identity says nothing there. What it can't see into — functions, a `Map`,
|
|
1298
|
+
* a class instance — it counts as the same, because a check that warns about
|
|
1299
|
+
* a correct element is worse than one that misses a case.
|
|
1300
|
+
*/
|
|
1301
|
+
function sameContent(a, b, seen = new Set()) {
|
|
1302
|
+
if (Object.is(a, b))
|
|
1303
|
+
return true;
|
|
1304
|
+
if (typeof a === 'function' && typeof b === 'function')
|
|
1305
|
+
return true;
|
|
1306
|
+
if (typeof a !== 'object' || typeof b !== 'object' || !a || !b)
|
|
1307
|
+
return false;
|
|
1308
|
+
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b))
|
|
1309
|
+
return false;
|
|
1310
|
+
if (a instanceof Date)
|
|
1311
|
+
return a.getTime() === b.getTime();
|
|
1312
|
+
const plain = Array.isArray(a) || Object.getPrototypeOf(a) === Object.prototype;
|
|
1313
|
+
if (!plain || seen.has(a))
|
|
1314
|
+
return true;
|
|
1315
|
+
seen.add(a);
|
|
1316
|
+
const keys = Object.keys(a);
|
|
1317
|
+
if (keys.length !== Object.keys(b).length)
|
|
1318
|
+
return false;
|
|
1319
|
+
return keys.every((key) => Object.hasOwn(b, key) &&
|
|
1320
|
+
sameContent(a[key], b[key], seen));
|
|
1321
|
+
}
|
|
1322
|
+
/** Element keys already warned about by the check on skipped writes. */
|
|
1323
|
+
const warnedSkippedWrites = new Set();
|
|
1324
|
+
const EMPTY_READS = new Set();
|
|
1325
|
+
function sameReads(a, b) {
|
|
1326
|
+
if (a === b)
|
|
1327
|
+
return true;
|
|
1328
|
+
if (!a || !b || a.size !== b.size)
|
|
1329
|
+
return false;
|
|
1330
|
+
for (const path of a)
|
|
1331
|
+
if (!b.has(path))
|
|
1332
|
+
return false;
|
|
1333
|
+
return true;
|
|
1334
|
+
}
|
|
1120
1335
|
/** The chain from the root down to `path`, as a line a human can follow. */
|
|
1121
1336
|
function describePath(path) {
|
|
1122
1337
|
const keys = [];
|
|
@@ -1142,11 +1357,71 @@ class JrElement {
|
|
|
1142
1357
|
devtoolsActive = injectDevtoolsActive();
|
|
1143
1358
|
/** The raw (unresolved) element from the spec. */
|
|
1144
1359
|
rawElement = computed(() => this.root.spec()?.elements?.[this.elementKey()], ...(ngDevMode ? [{ debugName: "rawElement" }] : /* istanbul ignore next */ []));
|
|
1360
|
+
/**
|
|
1361
|
+
* The state paths this element's expressions can read, or null when the
|
|
1362
|
+
* spec doesn't say — see {@link collectStateReads} for when that is.
|
|
1363
|
+
*/
|
|
1364
|
+
stateReads = computed(() => {
|
|
1365
|
+
const el = this.rawElement();
|
|
1366
|
+
if (!el)
|
|
1367
|
+
return EMPTY_READS;
|
|
1368
|
+
return collectStateReads(el, this.root.directiveRegistry(), this.repeatScope?.basePath());
|
|
1369
|
+
}, { ...(ngDevMode ? { debugName: "stateReads" } : /* istanbul ignore next */ {}), equal: sameReads });
|
|
1370
|
+
/**
|
|
1371
|
+
* One signal per path this element reads, each holding the value at that
|
|
1372
|
+
* path. They are rebuilt only when the paths change, and a cell that stays
|
|
1373
|
+
* keeps its last value, so a path that didn't change is not a change.
|
|
1374
|
+
*
|
|
1375
|
+
* A cell compares like a resolved prop does: primitives by value, objects
|
|
1376
|
+
* by reference with the internal store (which copies every path it writes)
|
|
1377
|
+
* and never equal with an external `store`, which may write into its
|
|
1378
|
+
* snapshot in place.
|
|
1379
|
+
*/
|
|
1380
|
+
stateCells = (() => {
|
|
1381
|
+
let cells = new Map();
|
|
1382
|
+
return computed(() => {
|
|
1383
|
+
const reads = this.stateReads();
|
|
1384
|
+
if (reads === null)
|
|
1385
|
+
return null;
|
|
1386
|
+
const next = new Map();
|
|
1387
|
+
for (const path of reads) {
|
|
1388
|
+
next.set(path, cells.get(path) ??
|
|
1389
|
+
computed(() => getByPath(this.state.state(), path), {
|
|
1390
|
+
equal: (a, b) => Object.is(a, b) &&
|
|
1391
|
+
(typeof a !== 'object' ||
|
|
1392
|
+
a === null ||
|
|
1393
|
+
!untracked(this.root.store)),
|
|
1394
|
+
}));
|
|
1395
|
+
}
|
|
1396
|
+
cells = next;
|
|
1397
|
+
return next;
|
|
1398
|
+
});
|
|
1399
|
+
})();
|
|
1400
|
+
/**
|
|
1401
|
+
* How many times {@link readableState} has taken a snapshot. The dev-mode
|
|
1402
|
+
* check compares it between state writes to tell which ones were skipped.
|
|
1403
|
+
*/
|
|
1404
|
+
snapshotsTaken = 0;
|
|
1405
|
+
/**
|
|
1406
|
+
* The state to resolve against: the whole snapshot, but only as fresh as
|
|
1407
|
+
* the paths this element reads. A write elsewhere doesn't reach it, so the
|
|
1408
|
+
* element doesn't resolve anything for it. An element whose reads are
|
|
1409
|
+
* unknown follows every write, as all elements did before.
|
|
1410
|
+
*/
|
|
1411
|
+
readableState = computed(() => {
|
|
1412
|
+
this.snapshotsTaken++;
|
|
1413
|
+
const cells = this.stateCells();
|
|
1414
|
+
if (cells === null)
|
|
1415
|
+
return this.state.state();
|
|
1416
|
+
for (const cell of cells.values())
|
|
1417
|
+
cell();
|
|
1418
|
+
return untracked(this.state.state);
|
|
1419
|
+
}, { ...(ngDevMode ? { debugName: "readableState" } : /* istanbul ignore next */ {}), equal: () => false });
|
|
1145
1420
|
/** Prop/visibility resolution context (state + repeat scope + extensions). */
|
|
1146
1421
|
resolutionCtx = computed(() => {
|
|
1147
1422
|
const scope = this.repeatScope;
|
|
1148
1423
|
return {
|
|
1149
|
-
stateModel: this.
|
|
1424
|
+
stateModel: this.readableState(),
|
|
1150
1425
|
...(scope
|
|
1151
1426
|
? {
|
|
1152
1427
|
repeatItem: scope.item(),
|
|
@@ -1288,6 +1563,25 @@ class JrElement {
|
|
|
1288
1563
|
console.warn(`[ngx-json-render] renderLimits.maxDepth (${untracked(this.root.limits)?.maxDepth}) reached at ${describePath(path)}. "${path.key}" and anything below it do not render.`);
|
|
1289
1564
|
}
|
|
1290
1565
|
});
|
|
1566
|
+
// Dev mode: after a state write this element skipped, check that it was
|
|
1567
|
+
// right to. Resolving against the whole state has to give what the element
|
|
1568
|
+
// already shows; anything else is a read the spec analysis didn't name.
|
|
1569
|
+
if (inject(CHECK_SKIPPED_WRITES)) {
|
|
1570
|
+
let snapshotsSeen = -1;
|
|
1571
|
+
effect(() => {
|
|
1572
|
+
this.state.state();
|
|
1573
|
+
const shown = this.resolvedElement();
|
|
1574
|
+
const shownVisible = this.visible();
|
|
1575
|
+
const reads = this.stateReads();
|
|
1576
|
+
untracked(() => {
|
|
1577
|
+
const skipped = this.snapshotsTaken === snapshotsSeen;
|
|
1578
|
+
snapshotsSeen = this.snapshotsTaken;
|
|
1579
|
+
if (skipped && shown && reads !== null) {
|
|
1580
|
+
this.checkSkippedWrite(shown, shownVisible, reads);
|
|
1581
|
+
}
|
|
1582
|
+
});
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1291
1585
|
// Warn (once per type) about unknown component types.
|
|
1292
1586
|
const warnedTypes = new Set();
|
|
1293
1587
|
effect(() => {
|
|
@@ -1349,6 +1643,37 @@ class JrElement {
|
|
|
1349
1643
|
onCleanup(unsubscribe);
|
|
1350
1644
|
});
|
|
1351
1645
|
}
|
|
1646
|
+
/**
|
|
1647
|
+
* Resolve this element against the whole current state and warn, once per
|
|
1648
|
+
* element key, when that differs from what it shows after skipping a write.
|
|
1649
|
+
*
|
|
1650
|
+
* It only reports, and leaves the element as it is: correcting it here would
|
|
1651
|
+
* make dev mode render what production doesn't.
|
|
1652
|
+
*/
|
|
1653
|
+
checkSkippedWrite(shown, shownVisible, reads) {
|
|
1654
|
+
const key = this.elementKey();
|
|
1655
|
+
const el = this.rawElement();
|
|
1656
|
+
if (!el || warnedSkippedWrites.has(key))
|
|
1657
|
+
return;
|
|
1658
|
+
const ctx = this.liveResolutionCtx();
|
|
1659
|
+
let props;
|
|
1660
|
+
let visible;
|
|
1661
|
+
try {
|
|
1662
|
+
props = resolveElementProps(el.props ?? {}, ctx);
|
|
1663
|
+
visible = el.visible === undefined || evaluateVisibility(el.visible, ctx);
|
|
1664
|
+
}
|
|
1665
|
+
catch {
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
const differs = Object.keys({ ...props, ...shown.props }).filter((prop) => !sameContent(props[prop], shown.props[prop]));
|
|
1669
|
+
if (visible !== shownVisible)
|
|
1670
|
+
differs.push('visible');
|
|
1671
|
+
if (differs.length === 0)
|
|
1672
|
+
return;
|
|
1673
|
+
warnedSkippedWrites.add(key);
|
|
1674
|
+
const paths = reads.size > 0 ? [...reads].join(', ') : 'no state paths';
|
|
1675
|
+
console.warn(`[ngx-json-render] "${key}" did not resolve again after a state write, but resolving it now changes ${differs.join(', ')}. It resolves only when a state path it reads changes, and it reads ${paths}. Either a $computed function in it depends on something besides its args, or the renderer missed a read — please report the spec at https://github.com/shteynu/ngx-json-render/issues. This check runs in dev mode only.`);
|
|
1676
|
+
}
|
|
1352
1677
|
/**
|
|
1353
1678
|
* Resolution context with a live state snapshot, so `$state` references in
|
|
1354
1679
|
* later actions of a chain see mutations from earlier ones.
|
|
@@ -1573,63 +1898,219 @@ function depthIssue(depth, limit) {
|
|
|
1573
1898
|
};
|
|
1574
1899
|
}
|
|
1575
1900
|
/**
|
|
1576
|
-
* Check a spec against a catalog: the component types it names,
|
|
1577
|
-
* catalog's
|
|
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.
|
|
1904
|
+
*
|
|
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.
|
|
1578
1914
|
*
|
|
1579
|
-
* The schema
|
|
1580
|
-
* its
|
|
1581
|
-
*
|
|
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.
|
|
1582
1918
|
*/
|
|
1583
1919
|
function catalogIssues(spec, catalog) {
|
|
1584
|
-
const
|
|
1920
|
+
const elements = Object.entries(spec.elements ?? {});
|
|
1585
1921
|
const known = new Set(catalog.componentNames);
|
|
1586
|
-
|
|
1922
|
+
const unknown = [];
|
|
1923
|
+
for (const [key, element] of elements) {
|
|
1587
1924
|
if (known.has(element.type))
|
|
1588
1925
|
continue;
|
|
1589
|
-
|
|
1926
|
+
unknown.push({
|
|
1590
1927
|
severity: 'error',
|
|
1591
1928
|
code: 'unknown_component',
|
|
1592
1929
|
elementKey: key,
|
|
1593
1930
|
message: `Element "${key}" has type "${element.type}", which this catalog does not define. Nothing renders for it.`,
|
|
1594
1931
|
});
|
|
1595
1932
|
}
|
|
1596
|
-
if (
|
|
1597
|
-
return
|
|
1598
|
-
const
|
|
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
|
+
}
|
|
1599
2006
|
if (result.success)
|
|
1600
|
-
return
|
|
1601
|
-
|
|
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('$')));
|
|
1602
2049
|
}
|
|
1603
2050
|
/**
|
|
1604
|
-
*
|
|
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.
|
|
1605
2078
|
*
|
|
1606
2079
|
* Read defensively rather than typed against the schema library: the shape
|
|
1607
2080
|
* below (`issues`, each with `path` and `message`) is what every version of
|
|
1608
2081
|
* it has produced, and a renderer that threw while explaining a bad spec
|
|
1609
2082
|
* would be the worse failure.
|
|
1610
2083
|
*/
|
|
1611
|
-
function
|
|
2084
|
+
function readIssues(error) {
|
|
1612
2085
|
const raw = error?.issues;
|
|
1613
2086
|
if (!Array.isArray(raw))
|
|
1614
2087
|
return [];
|
|
1615
|
-
return raw.
|
|
2088
|
+
return raw.map((entry) => {
|
|
1616
2089
|
const item = entry;
|
|
1617
|
-
const path =
|
|
1618
|
-
const message =
|
|
1619
|
-
// Paths are rooted at the spec, so `elements.<key>.props.<prop>` names the
|
|
1620
|
-
// element in its second segment.
|
|
1621
|
-
const elementKey = path[0] === 'elements' && typeof path[1] === 'string'
|
|
1622
|
-
? path[1]
|
|
1623
|
-
: undefined;
|
|
1624
|
-
const where = path.length > 0 ? path.join('.') : 'spec';
|
|
2090
|
+
const path = item?.path;
|
|
2091
|
+
const message = item?.message;
|
|
1625
2092
|
return {
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
...(elementKey === undefined ? {} : { elementKey }),
|
|
1629
|
-
message: `${where}: ${message}`,
|
|
2093
|
+
path: Array.isArray(path) ? path : [],
|
|
2094
|
+
message: typeof message === 'string' ? message : 'is not valid',
|
|
1630
2095
|
};
|
|
1631
2096
|
});
|
|
1632
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
|
+
}
|
|
1633
2114
|
|
|
1634
2115
|
const CLEAN = {
|
|
1635
2116
|
spec: null,
|
|
@@ -1814,7 +2295,11 @@ class JsonRenderer {
|
|
|
1814
2295
|
/**
|
|
1815
2296
|
* The catalog the spec was generated for. Given one, `validate` also checks
|
|
1816
2297
|
* what structure alone cannot: that every `type` is a component the catalog
|
|
1817
|
-
* defines,
|
|
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.
|
|
1818
2303
|
*
|
|
1819
2304
|
* Only read when `validate` is on, and — like the rest of the check —
|
|
1820
2305
|
* skipped while `loading`.
|