@wocart/theme 1.5.0 → 2.0.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/CHANGELOG.md +110 -0
- package/README.md +226 -11
- package/dist/astro/Block.astro +29 -0
- package/dist/astro/Blocks.astro +23 -0
- package/dist/astro/InstanceView.astro +34 -10
- package/dist/astro/ThemeStyles.astro +34 -0
- package/dist/astro/index.d.ts +49 -1
- package/dist/astro/index.js +43 -5
- package/dist/blocks.d.ts +61 -0
- package/dist/blocks.js +144 -0
- package/dist/conditions.d.ts +21 -0
- package/dist/conditions.js +65 -0
- package/dist/definitions.js +399 -29
- package/dist/index.d.ts +7 -2
- package/dist/index.js +4 -0
- package/dist/instances.d.ts +4 -0
- package/dist/instances.js +72 -4
- package/dist/manifest-diff.js +63 -2
- package/dist/manifest.d.ts +245 -13
- package/dist/manifest.js +64 -1
- package/dist/operations.d.ts +39 -13
- package/dist/operations.js +253 -55
- package/dist/page-data.js +63 -27
- package/dist/preview-bridge.js +162 -1
- package/dist/preview-protocol.d.ts +53 -1
- package/dist/rich-text.d.ts +2 -0
- package/dist/rich-text.js +4 -0
- package/dist/schemas.d.ts +2 -1
- package/dist/schemas.js +3 -1
- package/dist/settings.js +20 -1
- package/dist/style.d.ts +45 -0
- package/dist/style.js +248 -0
- package/dist/surface-document.d.ts +49 -0
- package/dist/surface-document.js +162 -0
- package/dist/types.d.ts +217 -7
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -1
package/dist/astro/index.js
CHANGED
|
@@ -5,18 +5,26 @@
|
|
|
5
5
|
* - `createReadCache` — the per-render read sharing the contract requires
|
|
6
6
|
* (one read per resource per render).
|
|
7
7
|
* - `themeManifestEndpoint` — the manifest as a prerendered build artifact.
|
|
8
|
+
* - `themeStylesEndpoint` — the stylesheet of the theme's style classes, the
|
|
9
|
+
* same way; `THEME_STYLES_PATH` and `colorSchemeCss` for `ThemeStyles.astro`.
|
|
8
10
|
* - `RendererIssue` — the renderer-specific degradations, reportable via
|
|
9
11
|
* `InstanceView`'s `onIssue` prop.
|
|
10
|
-
* - `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* - `BlocksHandle` and `hasBlocks` — what a component receives as `blocks`,
|
|
13
|
+
* and whether one of its slots has anything to render.
|
|
14
|
+
* - `resolveInstance` and `THEME_LIMITS` — re-exported so `InstanceView.astro`'s
|
|
15
|
+
* co-located import (`./index.js`) resolves identically in the dev source
|
|
16
|
+
* layout and in the published `dist/astro` layout. The same values as the
|
|
13
17
|
* root export — never a second implementation.
|
|
14
18
|
*
|
|
15
|
-
* The renderer
|
|
16
|
-
* `@wocart/theme/astro/
|
|
19
|
+
* The renderer components: `@wocart/theme/astro/InstanceView.astro`,
|
|
20
|
+
* `@wocart/theme/astro/Blocks.astro`, `@wocart/theme/astro/Block.astro` and
|
|
21
|
+
* `@wocart/theme/astro/ThemeStyles.astro`.
|
|
17
22
|
*/
|
|
18
23
|
import { themeManifest, validateThemeManifest } from "../manifest.js";
|
|
24
|
+
import { themeStyleSheet } from "../style.js";
|
|
19
25
|
export { resolveInstance } from "../instances.js";
|
|
26
|
+
export { THEME_LIMITS } from "../blocks.js";
|
|
27
|
+
export { THEME_STYLES_PATH, colorSchemeCss } from "../style.js";
|
|
20
28
|
export { previewEndMarker, previewStartMarker } from "../preview-protocol.js";
|
|
21
29
|
/**
|
|
22
30
|
* The theme's manifest as a prerendered endpoint, the file a release and an
|
|
@@ -43,6 +51,36 @@ export function themeManifestEndpoint(theme) {
|
|
|
43
51
|
const body = JSON.stringify(manifest);
|
|
44
52
|
return () => new Response(body, { headers: { "Content-Type": "application/json; charset=utf-8" } });
|
|
45
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The stylesheet of the theme's style classes as a prerendered endpoint, the
|
|
56
|
+
* file `<ThemeStyles>` links (`THEME_STYLES_PATH`):
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* // src/pages/wocart-styles.css.ts
|
|
60
|
+
* import { themeStylesEndpoint } from "@wocart/theme/astro";
|
|
61
|
+
* import { theme } from "../theme/theme";
|
|
62
|
+
* export const prerender = true;
|
|
63
|
+
* export const GET = themeStylesEndpoint(theme);
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function themeStylesEndpoint(theme) {
|
|
67
|
+
const body = themeStyleSheet(theme.style);
|
|
68
|
+
return () => new Response(body, { headers: { "Content-Type": "text/css; charset=utf-8" } });
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Whether the component renders in an editor's preview. A section with
|
|
72
|
+
* nothing to show yet — no products picked, no text written — draws a
|
|
73
|
+
* placeholder there, so a merchant can see and select it; a shopper's page
|
|
74
|
+
* never shows one.
|
|
75
|
+
*/
|
|
76
|
+
export function isPreview(blocks) {
|
|
77
|
+
return blocks?.render.preview === true;
|
|
78
|
+
}
|
|
79
|
+
/** Whether a slot holds a block that renders (not hidden), so a component can leave out an empty wrapper. */
|
|
80
|
+
export function hasBlocks(blocks, slot = "default") {
|
|
81
|
+
const list = blocks !== undefined && Object.hasOwn(blocks.slots, slot) ? blocks.slots[slot] : [];
|
|
82
|
+
return list.some((instance) => instance.hidden !== true);
|
|
83
|
+
}
|
|
46
84
|
/**
|
|
47
85
|
* The per-render `ReadCache`: the first call for a key
|
|
48
86
|
* runs the loader; later calls in the same render reuse the in-flight or
|
package/dist/blocks.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a document holds whatever it stores: every fixed block a
|
|
3
|
+
* component's code places, every required type a slot or surface lists, and
|
|
4
|
+
* the platform's caps. One implementation serves the renderer
|
|
5
|
+
* (`resolveInstance`), the reader (`validatePageData`) and every writer
|
|
6
|
+
* (`completePage`), so what an editor shows is what renders.
|
|
7
|
+
*/
|
|
8
|
+
import type { ComponentDefinition, ComponentInstance, ComponentPlacement, ComponentRole, PageData, PageDataIssue, RequiredBlock, SlotDefinition, SurfaceSpec, ThemeDefinition } from "./types.js";
|
|
9
|
+
/** The platform's caps: a page renders within a Worker's CPU time, and an editor's tree stays readable. */
|
|
10
|
+
export declare const THEME_LIMITS: {
|
|
11
|
+
/** Instances in one slot. */
|
|
12
|
+
readonly slotInstances: 50;
|
|
13
|
+
/** Top-level sections of one surface or area. */
|
|
14
|
+
readonly sections: 50;
|
|
15
|
+
/** Instances in one document, at every depth. */
|
|
16
|
+
readonly documentInstances: 1000;
|
|
17
|
+
/** Levels of nesting below a top-level section. */
|
|
18
|
+
readonly depth: 6;
|
|
19
|
+
};
|
|
20
|
+
export declare const COMPONENT_PLACEMENTS: readonly ["section", "block", "both"];
|
|
21
|
+
export declare const COMPONENT_ROLES: readonly ["order-form"];
|
|
22
|
+
/** The surface an area's document is stored under: `area:<key>`. */
|
|
23
|
+
export declare function areaSurface(key: string): string;
|
|
24
|
+
/** The shipped document for a surface or an area, when the theme declares one. */
|
|
25
|
+
export declare function shippedDocument(theme: ThemeDefinition, surface: string): PageData | undefined;
|
|
26
|
+
/** Whether a theme composes a surface: one it declares, or one of its areas. */
|
|
27
|
+
export declare function composesSurface(theme: ThemeDefinition, surface: string): boolean;
|
|
28
|
+
/** The id a fixed block renders with while the document does not store it. */
|
|
29
|
+
export declare function fixedBlockId(ownerId: string, name: string): string;
|
|
30
|
+
/** Whether a component may be placed at the top level (`"section"`) or in a slot (`"block"`). */
|
|
31
|
+
export declare function placementAllows(placement: ComponentPlacement | undefined, where: "section" | "block"): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* The block rules a surface's top level follows: its own `required` and
|
|
34
|
+
* `max`, each inherited from the base surface by an alternate
|
|
35
|
+
* (`product.compact` from `product`) that does not set its own.
|
|
36
|
+
*/
|
|
37
|
+
export declare function surfaceRules(theme: ThemeDefinition, surface: string): Pick<SurfaceSpec, "required" | "max">;
|
|
38
|
+
/** A slot's or surface's cap: its own `max`, never above the platform's. */
|
|
39
|
+
export declare function slotCap(slot: Pick<SlotDefinition, "max"> | undefined, platform: number): number;
|
|
40
|
+
/**
|
|
41
|
+
* Every fixed block of an instance, as it renders: the stored value when its
|
|
42
|
+
* type is the code's, else the code's type with its defaults. A fixed block
|
|
43
|
+
* is never hidden. Stored entries the definition does not declare are reported.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveFixed(definition: ComponentDefinition, instance: ComponentInstance, issues: PageDataIssue[]): Record<string, ComponentInstance>;
|
|
46
|
+
/**
|
|
47
|
+
* A list with every required type present: hidden ones shown again first, then
|
|
48
|
+
* new instances (`{ id, type }`, values from the defaults) added at the end.
|
|
49
|
+
* `taken` keeps added ids unique in a whole document. Never mutates `list`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function completeRequired(list: readonly ComponentInstance[], required: readonly RequiredBlock[] | undefined, owner: {
|
|
52
|
+
id: string;
|
|
53
|
+
slot: string;
|
|
54
|
+
}, path: string, issues: PageDataIssue[], taken?: Set<string>): ComponentInstance[];
|
|
55
|
+
/** How many instances of `role` a `type` always renders: its own, its fixed blocks', its slots' required minimums. */
|
|
56
|
+
export declare function guaranteedRole(theme: ThemeDefinition, type: string, role: ComponentRole, visiting?: ReadonlySet<string>): number;
|
|
57
|
+
/** The types a component always renders inside itself: fixed blocks and required types, by slot. */
|
|
58
|
+
export declare function innerGuarantees(definition: ComponentDefinition): Array<{
|
|
59
|
+
type: string;
|
|
60
|
+
via: string;
|
|
61
|
+
}>;
|
package/dist/blocks.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/** The platform's caps: a page renders within a Worker's CPU time, and an editor's tree stays readable. */
|
|
2
|
+
export const THEME_LIMITS = {
|
|
3
|
+
/** Instances in one slot. */
|
|
4
|
+
slotInstances: 50,
|
|
5
|
+
/** Top-level sections of one surface or area. */
|
|
6
|
+
sections: 50,
|
|
7
|
+
/** Instances in one document, at every depth. */
|
|
8
|
+
documentInstances: 1000,
|
|
9
|
+
/** Levels of nesting below a top-level section. */
|
|
10
|
+
depth: 6,
|
|
11
|
+
};
|
|
12
|
+
export const COMPONENT_PLACEMENTS = ["section", "block", "both"];
|
|
13
|
+
export const COMPONENT_ROLES = ["order-form"];
|
|
14
|
+
const AREA_PREFIX = "area:";
|
|
15
|
+
/** The surface an area's document is stored under: `area:<key>`. */
|
|
16
|
+
export function areaSurface(key) {
|
|
17
|
+
return `${AREA_PREFIX}${key}`;
|
|
18
|
+
}
|
|
19
|
+
/** The shipped document for a surface or an area, when the theme declares one. */
|
|
20
|
+
export function shippedDocument(theme, surface) {
|
|
21
|
+
if (surface.startsWith(AREA_PREFIX))
|
|
22
|
+
return (theme.areas ?? []).find((area) => areaSurface(area.key) === surface)?.document;
|
|
23
|
+
return (theme.surfaces ?? []).find((spec) => spec.key === surface)?.document;
|
|
24
|
+
}
|
|
25
|
+
/** Whether a theme composes a surface: one it declares, or one of its areas. */
|
|
26
|
+
export function composesSurface(theme, surface) {
|
|
27
|
+
return (theme.surfaces ?? []).some((spec) => spec.key === surface) || (theme.areas ?? []).some((area) => areaSurface(area.key) === surface);
|
|
28
|
+
}
|
|
29
|
+
/** The id a fixed block renders with while the document does not store it. */
|
|
30
|
+
export function fixedBlockId(ownerId, name) {
|
|
31
|
+
return `${ownerId}~${name}`;
|
|
32
|
+
}
|
|
33
|
+
const requiredBlockId = (ownerId, slot, type, n) => `${ownerId}~${slot}~${type}${n > 1 ? `~${n}` : ""}`;
|
|
34
|
+
/** Whether a component may be placed at the top level (`"section"`) or in a slot (`"block"`). */
|
|
35
|
+
export function placementAllows(placement, where) {
|
|
36
|
+
return placement === undefined || placement === "both" || placement === where;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The block rules a surface's top level follows: its own `required` and
|
|
40
|
+
* `max`, each inherited from the base surface by an alternate
|
|
41
|
+
* (`product.compact` from `product`) that does not set its own.
|
|
42
|
+
*/
|
|
43
|
+
export function surfaceRules(theme, surface) {
|
|
44
|
+
if (surface.startsWith(AREA_PREFIX)) {
|
|
45
|
+
const area = (theme.areas ?? []).find((spec) => areaSurface(spec.key) === surface);
|
|
46
|
+
return { ...(area?.required === undefined ? {} : { required: area.required }), ...(area?.max === undefined ? {} : { max: area.max }) };
|
|
47
|
+
}
|
|
48
|
+
const surfaces = theme.surfaces ?? [];
|
|
49
|
+
const own = surfaces.find((spec) => spec.key === surface);
|
|
50
|
+
const dot = surface.indexOf(".");
|
|
51
|
+
const base = dot > 0 ? surfaces.find((spec) => spec.key === surface.slice(0, dot)) : undefined;
|
|
52
|
+
const required = own?.required ?? base?.required;
|
|
53
|
+
const max = own?.max ?? base?.max;
|
|
54
|
+
return { ...(required === undefined ? {} : { required }), ...(max === undefined ? {} : { max }) };
|
|
55
|
+
}
|
|
56
|
+
/** A slot's or surface's cap: its own `max`, never above the platform's. */
|
|
57
|
+
export function slotCap(slot, platform) {
|
|
58
|
+
return slot?.max === undefined ? platform : Math.min(slot.max, platform);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Every fixed block of an instance, as it renders: the stored value when its
|
|
62
|
+
* type is the code's, else the code's type with its defaults. A fixed block
|
|
63
|
+
* is never hidden. Stored entries the definition does not declare are reported.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveFixed(definition, instance, issues) {
|
|
66
|
+
const declared = definition.fixed ?? [];
|
|
67
|
+
const stored = instance.fixed ?? {};
|
|
68
|
+
for (const name of Object.keys(stored)) {
|
|
69
|
+
if (!declared.some((block) => block.name === name)) {
|
|
70
|
+
issues.push({ path: `fixed.${name}`, code: "unknown-fixed", message: `"${definition.type}" places no fixed block "${name}"; its value is ignored` });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const resolved = {};
|
|
74
|
+
for (const { name, type } of declared) {
|
|
75
|
+
const entry = Object.hasOwn(stored, name) ? stored[name] : undefined;
|
|
76
|
+
if (entry !== undefined && entry.type === type) {
|
|
77
|
+
const { hidden: _hidden, ...shown } = entry;
|
|
78
|
+
resolved[name] = shown;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (entry !== undefined) {
|
|
82
|
+
issues.push({
|
|
83
|
+
path: `fixed.${name}.type`,
|
|
84
|
+
code: "fixed-type-mismatch",
|
|
85
|
+
message: `the fixed block "${name}" is a "${type}", not a "${entry.type}"; it renders with its defaults`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
resolved[name] = { id: fixedBlockId(instance.id, name), type };
|
|
89
|
+
}
|
|
90
|
+
return resolved;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A list with every required type present: hidden ones shown again first, then
|
|
94
|
+
* new instances (`{ id, type }`, values from the defaults) added at the end.
|
|
95
|
+
* `taken` keeps added ids unique in a whole document. Never mutates `list`.
|
|
96
|
+
*/
|
|
97
|
+
export function completeRequired(list, required, owner, path, issues, taken) {
|
|
98
|
+
let result = [...list];
|
|
99
|
+
for (const { type, min = 1 } of required ?? []) {
|
|
100
|
+
let shown = result.filter((instance) => instance.type === type && instance.hidden !== true).length;
|
|
101
|
+
if (shown >= min)
|
|
102
|
+
continue;
|
|
103
|
+
result = result.map((instance) => {
|
|
104
|
+
if (shown >= min || instance.type !== type || instance.hidden !== true)
|
|
105
|
+
return instance;
|
|
106
|
+
shown += 1;
|
|
107
|
+
issues.push({ path, code: "missing-required-block", message: `"${type}" is required in "${owner.slot}" and cannot be hidden; it is shown` });
|
|
108
|
+
const { hidden: _hidden, ...visible } = instance;
|
|
109
|
+
return visible;
|
|
110
|
+
});
|
|
111
|
+
for (let n = 1; shown < min; n += 1) {
|
|
112
|
+
const id = requiredBlockId(owner.id, owner.slot, type, n);
|
|
113
|
+
if (result.some((instance) => instance.id === id) || taken?.has(id) === true)
|
|
114
|
+
continue;
|
|
115
|
+
taken?.add(id);
|
|
116
|
+
result.push({ id, type });
|
|
117
|
+
shown += 1;
|
|
118
|
+
issues.push({ path, code: "missing-required-block", message: `"${type}" is required in "${owner.slot}"; one was added` });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
/** How many instances of `role` a `type` always renders: its own, its fixed blocks', its slots' required minimums. */
|
|
124
|
+
export function guaranteedRole(theme, type, role, visiting = new Set()) {
|
|
125
|
+
const definition = Object.hasOwn(theme.components, type) ? theme.components[type] : undefined;
|
|
126
|
+
if (definition === undefined || visiting.has(type))
|
|
127
|
+
return 0;
|
|
128
|
+
const inner = new Set(visiting).add(type);
|
|
129
|
+
let count = definition.role === role ? 1 : 0;
|
|
130
|
+
for (const block of definition.fixed ?? [])
|
|
131
|
+
count += guaranteedRole(theme, block.type, role, inner);
|
|
132
|
+
for (const slot of definition.slots ?? []) {
|
|
133
|
+
for (const entry of slot.required ?? [])
|
|
134
|
+
count += (entry.min ?? 1) * guaranteedRole(theme, entry.type, role, inner);
|
|
135
|
+
}
|
|
136
|
+
return count;
|
|
137
|
+
}
|
|
138
|
+
/** The types a component always renders inside itself: fixed blocks and required types, by slot. */
|
|
139
|
+
export function innerGuarantees(definition) {
|
|
140
|
+
return [
|
|
141
|
+
...(definition.fixed ?? []).map((block) => ({ type: block.type, via: `fixed "${block.name}"` })),
|
|
142
|
+
...(definition.slots ?? []).flatMap((slot) => (slot.required ?? []).map((entry) => ({ type: entry.type, via: `slot "${slot.name}"` }))),
|
|
143
|
+
];
|
|
144
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a field applies (`visibleIf`): a closed JSON condition on the values of
|
|
3
|
+
* the fields beside it, evaluated the same way by every editor. Conditions
|
|
4
|
+
* only decide what an editor shows; the renderer ignores them, and a hidden
|
|
5
|
+
* field keeps its value.
|
|
6
|
+
*/
|
|
7
|
+
import type { FieldDefinition } from "./types.js";
|
|
8
|
+
/** How deep `all`, `any` and `not` may nest. */
|
|
9
|
+
export declare const CONDITION_DEPTH = 4;
|
|
10
|
+
/**
|
|
11
|
+
* Whether an editor shows `field`, given the current values of the fields
|
|
12
|
+
* beside it — what is stored, else the default. A field with no value
|
|
13
|
+
* compares as `null`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isFieldVisible(field: FieldDefinition, values: Readonly<Record<string, unknown>>): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Why `condition` is not one a field named `self` may carry, or `null`: one
|
|
18
|
+
* of the five shapes, naming only fields in `siblings` other than itself,
|
|
19
|
+
* comparing with JSON scalars, nested at most `CONDITION_DEPTH` deep.
|
|
20
|
+
*/
|
|
21
|
+
export declare function conditionProblem(condition: unknown, siblings: ReadonlySet<string>, self: string, depth?: number): string | null;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** How deep `all`, `any` and `not` may nest. */
|
|
2
|
+
export const CONDITION_DEPTH = 4;
|
|
3
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
const isConditionValue = (value) => value === null || typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value));
|
|
5
|
+
/**
|
|
6
|
+
* Whether an editor shows `field`, given the current values of the fields
|
|
7
|
+
* beside it — what is stored, else the default. A field with no value
|
|
8
|
+
* compares as `null`.
|
|
9
|
+
*/
|
|
10
|
+
export function isFieldVisible(field, values) {
|
|
11
|
+
return field.visibleIf === undefined || holds(field.visibleIf, values);
|
|
12
|
+
}
|
|
13
|
+
function holds(condition, values) {
|
|
14
|
+
if ("all" in condition)
|
|
15
|
+
return condition.all.every((inner) => holds(inner, values));
|
|
16
|
+
if ("any" in condition)
|
|
17
|
+
return condition.any.some((inner) => holds(inner, values));
|
|
18
|
+
if ("not" in condition)
|
|
19
|
+
return !holds(condition.not, values);
|
|
20
|
+
const value = Object.hasOwn(values, condition.field) ? values[condition.field] : undefined;
|
|
21
|
+
const actual = value === undefined ? null : value;
|
|
22
|
+
if ("equals" in condition)
|
|
23
|
+
return actual === condition.equals;
|
|
24
|
+
return condition.in.includes(actual);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Why `condition` is not one a field named `self` may carry, or `null`: one
|
|
28
|
+
* of the five shapes, naming only fields in `siblings` other than itself,
|
|
29
|
+
* comparing with JSON scalars, nested at most `CONDITION_DEPTH` deep.
|
|
30
|
+
*/
|
|
31
|
+
export function conditionProblem(condition, siblings, self, depth = 1) {
|
|
32
|
+
if (depth > CONDITION_DEPTH)
|
|
33
|
+
return `nests more than ${CONDITION_DEPTH} levels`;
|
|
34
|
+
if (!isRecord(condition))
|
|
35
|
+
return "must be a condition: { field, equals }, { field, in }, { all }, { any } or { not }";
|
|
36
|
+
const keys = Object.keys(condition).sort().join(",");
|
|
37
|
+
if (keys === "all" || keys === "any") {
|
|
38
|
+
const list = condition[keys];
|
|
39
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
40
|
+
return `\`${keys}\` must be a non-empty array of conditions`;
|
|
41
|
+
for (const inner of list) {
|
|
42
|
+
const problem = conditionProblem(inner, siblings, self, depth + 1);
|
|
43
|
+
if (problem !== null)
|
|
44
|
+
return problem;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (keys === "not")
|
|
49
|
+
return conditionProblem(condition.not, siblings, self, depth + 1);
|
|
50
|
+
if (keys !== "equals,field" && keys !== "field,in") {
|
|
51
|
+
return "must be a condition: { field, equals }, { field, in }, { all }, { any } or { not }";
|
|
52
|
+
}
|
|
53
|
+
const field = condition.field;
|
|
54
|
+
if (typeof field !== "string" || !siblings.has(field))
|
|
55
|
+
return `${JSON.stringify(field)} is not a field beside this one`;
|
|
56
|
+
if (field === self)
|
|
57
|
+
return "a field cannot depend on itself";
|
|
58
|
+
if (keys === "equals,field")
|
|
59
|
+
return isConditionValue(condition.equals) ? null : "`equals` must be a string, a number, a boolean or null";
|
|
60
|
+
const options = condition.in;
|
|
61
|
+
if (!Array.isArray(options) || options.length === 0 || !options.every(isConditionValue)) {
|
|
62
|
+
return "`in` must be a non-empty array of strings, numbers, booleans or null";
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|