@cxpinsight/survey-spec 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.
- package/README.md +56 -0
- package/dist/expressionEval.d.ts +7 -0
- package/dist/expressionEval.d.ts.map +1 -0
- package/dist/expressionEval.js +336 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/resolveVariable.d.ts +19 -0
- package/dist/resolveVariable.d.ts.map +1 -0
- package/dist/resolveVariable.js +51 -0
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/package.json +30 -0
- package/src/expressionEval.d.ts +7 -0
- package/src/expressionEval.d.ts.map +1 -0
- package/src/expressionEval.js +337 -0
- package/src/expressionEval.js.map +1 -0
- package/src/expressionEval.ts +321 -0
- package/src/index.d.ts +16 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +22 -0
- package/src/index.js.map +1 -0
- package/src/index.ts +15 -0
- package/src/resolveVariable.d.ts +19 -0
- package/src/resolveVariable.d.ts.map +1 -0
- package/src/resolveVariable.js +52 -0
- package/src/resolveVariable.js.map +1 -0
- package/src/resolveVariable.ts +50 -0
- package/src/types.d.ts +17 -0
- package/src/types.d.ts.map +1 -0
- package/src/types.js +3 -0
- package/src/types.js.map +1 -0
- package/src/types.ts +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @cxpinsight/survey-spec
|
|
2
|
+
|
|
3
|
+
Behaviour that must be identical for a respondent regardless of how they arrived
|
|
4
|
+
— a link, a site running the web SDK, or a mobile app.
|
|
5
|
+
|
|
6
|
+
Three renderers implemented this independently, and drift was not hypothetical:
|
|
7
|
+
|
|
8
|
+
- the React tokenizer's check for "did the multi-character operator loop consume
|
|
9
|
+
something" was `src[i - 1] === src[i]`, which is not that question, so every
|
|
10
|
+
`showWhen` using `<=`, `>=`, `==`, `<>`, `&&` or `||` silently evaluated false
|
|
11
|
+
and **hid its question** — on the live respondent path for link surveys, while
|
|
12
|
+
mobile and web handled all six
|
|
13
|
+
- the web SDK could resolve only the `device` namespace, and only because it
|
|
14
|
+
faked it by concatenating `'device.'` onto a key, so `{{user.plan}}` and every
|
|
15
|
+
other namespace evaluated false there and true elsewhere
|
|
16
|
+
- an unparseable expression hid the question on two renderers and showed it on
|
|
17
|
+
the third
|
|
18
|
+
|
|
19
|
+
## Current scope
|
|
20
|
+
|
|
21
|
+
Expression evaluation: `evaluateExpression`, `evaluateCondition`, `isParseable`.
|
|
22
|
+
|
|
23
|
+
Page flow, validation, interpolation and the question-type registry are the
|
|
24
|
+
remaining candidates — all of them currently duplicated across the three
|
|
25
|
+
renderers.
|
|
26
|
+
|
|
27
|
+
## Who consumes it
|
|
28
|
+
|
|
29
|
+
| | |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| React renderer | imports this package; its `renderer/lib/expressionEval.ts` is now a re-export |
|
|
32
|
+
| Mobile SDK | still its own copy — migration pending |
|
|
33
|
+
| Web SDK | still its own copy, inline in `survey.js` — no module system, so it needs a generated ES5 build |
|
|
34
|
+
|
|
35
|
+
`tools/survey-conformance` runs identical fixtures through the spec and every
|
|
36
|
+
renderer and **fails on any disagreement**, so the two remaining copies cannot
|
|
37
|
+
drift while they are being migrated.
|
|
38
|
+
|
|
39
|
+
## Decisions encoded here
|
|
40
|
+
|
|
41
|
+
**An unparseable condition is ignored, not false.** A hidden question is
|
|
42
|
+
invisible in the response data — no answers, no error, no signal — so a typo used
|
|
43
|
+
to cause silent data loss. It now shows, and the builder flags the expression
|
|
44
|
+
using this package's own `isParseable`, so the warning and the runtime behaviour
|
|
45
|
+
cannot disagree.
|
|
46
|
+
|
|
47
|
+
**Namespaces are nested objects.** `variables.device.theme`, not
|
|
48
|
+
`variables['device.theme']`. That is what lets an SDK expose structured runtime
|
|
49
|
+
signals without the grammar growing new token kinds.
|
|
50
|
+
|
|
51
|
+
## Linking
|
|
52
|
+
|
|
53
|
+
This repo has no npm workspaces and no `file:` dependencies, so the frontend
|
|
54
|
+
resolves the package through a tsconfig path and a matching Vite alias rather
|
|
55
|
+
than an install. Introducing workspaces would change resolution for every other
|
|
56
|
+
package here, which is a bigger decision than this extraction.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { AnswersMap, VariablesMap, EvalContext } from './types';
|
|
2
|
+
import type { EvalContext } from './types';
|
|
3
|
+
export declare function evaluateExpression(source: string, ctx: EvalContext): unknown;
|
|
4
|
+
export declare function evaluateCondition(source: string | undefined, ctx: EvalContext): boolean;
|
|
5
|
+
/** Whether `source` parses at all, independent of what it evaluates to. */
|
|
6
|
+
export declare function isParseable(source: string | undefined): boolean;
|
|
7
|
+
//# sourceMappingURL=expressionEval.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"expressionEval.d.ts","sourceRoot":"","sources":["../src/expressionEval.ts"],"names":[],"mappings":"AAwBA,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AACrE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAI3C,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CAY5E;AAID,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CAcvF;AAED,2EAA2E;AAC3E,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAS/D"}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Expression evaluator — supports the subset of operators the spec
|
|
3
|
+
// commits to (spec §7, §22):
|
|
4
|
+
//
|
|
5
|
+
// Comparators: = != < > <= >=
|
|
6
|
+
// Boolean: and or not && || !
|
|
7
|
+
// Arithmetic: + - * /
|
|
8
|
+
// Refs: {q_id} the respondent's answer to q_id
|
|
9
|
+
// {{variable}} resolved variable value
|
|
10
|
+
// 'string' string literal
|
|
11
|
+
// 42 numeric literal
|
|
12
|
+
// true / false boolean literal
|
|
13
|
+
//
|
|
14
|
+
// This is deliberately a simple tokens-based evaluator, not a full
|
|
15
|
+
// JS-compatible expression engine. It's used for:
|
|
16
|
+
// - showWhen / enabledWhen / requiredWhen (question + section)
|
|
17
|
+
// - `expression` type questions (computed value)
|
|
18
|
+
// - `calculated` variable expressions
|
|
19
|
+
//
|
|
20
|
+
// Being cautious: an untyped eval() would let survey authors run
|
|
21
|
+
// arbitrary JS against respondent answers. This restricted grammar
|
|
22
|
+
// keeps the surface area bounded.
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.evaluateExpression = evaluateExpression;
|
|
25
|
+
exports.evaluateCondition = evaluateCondition;
|
|
26
|
+
exports.isParseable = isParseable;
|
|
27
|
+
// ─── Public entry point ───────────────────────────────────────────────
|
|
28
|
+
function evaluateExpression(source, ctx) {
|
|
29
|
+
if (typeof source !== 'string')
|
|
30
|
+
return undefined;
|
|
31
|
+
const src = source.trim();
|
|
32
|
+
if (!src)
|
|
33
|
+
return undefined;
|
|
34
|
+
try {
|
|
35
|
+
const tokens = tokenize(src);
|
|
36
|
+
const [value, consumed] = parseExpression(tokens, 0, ctx);
|
|
37
|
+
if (consumed !== tokens.length)
|
|
38
|
+
return undefined; // trailing garbage
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// showWhen/enabledWhen coerce the result to boolean. Empty / undefined
|
|
46
|
+
// evaluates to false (hides the question) — friendlier than throwing.
|
|
47
|
+
function evaluateCondition(source, ctx) {
|
|
48
|
+
if (!source)
|
|
49
|
+
return true; // no condition = always shown
|
|
50
|
+
// An expression that cannot be PARSED is not a false condition — it is not a
|
|
51
|
+
// condition. Treating a typo as "false" hid the question, and a hidden question
|
|
52
|
+
// is invisible in the response data: no answers, no error, no signal, and the
|
|
53
|
+
// author concludes nobody wanted to answer it. Silent data loss is the worse
|
|
54
|
+
// failure, so an invalid condition is ignored and the question shows. The
|
|
55
|
+
// builder is where a malformed expression should be surfaced, before it ships.
|
|
56
|
+
//
|
|
57
|
+
// A well-formed expression that evaluates falsy still hides the question, which
|
|
58
|
+
// is the whole point of showWhen. Only unparseable input is ignored.
|
|
59
|
+
if (!isParseable(source))
|
|
60
|
+
return true;
|
|
61
|
+
const result = evaluateExpression(source, ctx);
|
|
62
|
+
return truthy(result);
|
|
63
|
+
}
|
|
64
|
+
/** Whether `source` parses at all, independent of what it evaluates to. */
|
|
65
|
+
function isParseable(source) {
|
|
66
|
+
if (!source || !source.trim())
|
|
67
|
+
return true;
|
|
68
|
+
try {
|
|
69
|
+
const tokens = tokenize(source.trim());
|
|
70
|
+
const [, consumed] = parseExpression(tokens, 0, { variables: {}, answers: {} });
|
|
71
|
+
return consumed === tokens.length;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function tokenize(src) {
|
|
78
|
+
const tokens = [];
|
|
79
|
+
let i = 0;
|
|
80
|
+
const len = src.length;
|
|
81
|
+
while (i < len) {
|
|
82
|
+
const c = src[i];
|
|
83
|
+
// whitespace
|
|
84
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
|
|
85
|
+
i++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// string literal — single or double quoted
|
|
89
|
+
if (c === "'" || c === '"') {
|
|
90
|
+
const quote = c;
|
|
91
|
+
let out = '';
|
|
92
|
+
i++;
|
|
93
|
+
while (i < len && src[i] !== quote) {
|
|
94
|
+
if (src[i] === '\\' && i + 1 < len) {
|
|
95
|
+
out += src[i + 1];
|
|
96
|
+
i += 2;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
out += src[i];
|
|
100
|
+
i++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
i++; // closing quote
|
|
104
|
+
tokens.push({ kind: 'str', value: out });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// numeric literal
|
|
108
|
+
if ((c >= '0' && c <= '9') || (c === '-' && src[i + 1] >= '0' && src[i + 1] <= '9' && !prevExpectsBinary(tokens))) {
|
|
109
|
+
let j = i;
|
|
110
|
+
if (src[j] === '-')
|
|
111
|
+
j++;
|
|
112
|
+
while (j < len && ((src[j] >= '0' && src[j] <= '9') || src[j] === '.'))
|
|
113
|
+
j++;
|
|
114
|
+
tokens.push({ kind: 'num', value: parseFloat(src.slice(i, j)) });
|
|
115
|
+
i = j;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// {{variable_ref}} — spec §22 interpolation shape
|
|
119
|
+
if (c === '{' && src[i + 1] === '{') {
|
|
120
|
+
const end = src.indexOf('}}', i + 2);
|
|
121
|
+
if (end < 0)
|
|
122
|
+
throw new Error('unterminated {{');
|
|
123
|
+
const inner = src.slice(i + 2, end).trim();
|
|
124
|
+
const dot = inner.indexOf('.');
|
|
125
|
+
if (dot > 0) {
|
|
126
|
+
tokens.push({ kind: 'variable_ref', name: inner.slice(0, dot).trim(), suffix: inner.slice(dot + 1).trim() });
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
tokens.push({ kind: 'variable_ref', name: inner });
|
|
130
|
+
}
|
|
131
|
+
i = end + 2;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// {q_id} — answer reference (single-brace, spec §7)
|
|
135
|
+
if (c === '{') {
|
|
136
|
+
const end = src.indexOf('}', i + 1);
|
|
137
|
+
if (end < 0)
|
|
138
|
+
throw new Error('unterminated {');
|
|
139
|
+
tokens.push({ kind: 'answer_ref', name: src.slice(i + 1, end).trim() });
|
|
140
|
+
i = end + 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// parentheses
|
|
144
|
+
if (c === '(') {
|
|
145
|
+
tokens.push({ kind: 'lparen' });
|
|
146
|
+
i++;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (c === ')') {
|
|
150
|
+
tokens.push({ kind: 'rparen' });
|
|
151
|
+
i++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
// Multi-character operators. Order matters — longest first.
|
|
155
|
+
//
|
|
156
|
+
// The "did we consume one?" check used to be `src[i - 1] === src[i]`, which
|
|
157
|
+
// is not that question. After `<=` is consumed, `c` still holds `<` from the
|
|
158
|
+
// top of the loop, so the single-character branch below pushed a SECOND `<`
|
|
159
|
+
// token and advanced i again. The extra token made `consumed !== tokens.length`
|
|
160
|
+
// in evaluateExpression, which returns undefined, which is falsy — so every
|
|
161
|
+
// showWhen using <=, >=, ==, <> , && or || silently evaluated to FALSE and hid
|
|
162
|
+
// its question. Only `!=` survived, by accident of token ordering.
|
|
163
|
+
//
|
|
164
|
+
// This is the live respondent path for link surveys and SdkSurveyHost, while
|
|
165
|
+
// the mobile and web SDKs both tokenize correctly — so the same survey branched
|
|
166
|
+
// differently depending on how a respondent arrived. Mobile already carries
|
|
167
|
+
// this fix (survey/expressionEval.ts); this is that fix, ported back.
|
|
168
|
+
let matchedMulti = false;
|
|
169
|
+
for (const op of ['<=', '>=', '!=', '<>', '&&', '||', '==']) {
|
|
170
|
+
if (src.slice(i, i + op.length) === op) {
|
|
171
|
+
tokens.push({ kind: 'op', value: op === '==' ? '=' : op });
|
|
172
|
+
i += op.length;
|
|
173
|
+
matchedMulti = true;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (matchedMulti)
|
|
178
|
+
continue;
|
|
179
|
+
// Single-char operators
|
|
180
|
+
if ('=<>+-*/!'.includes(c)) {
|
|
181
|
+
tokens.push({ kind: 'op', value: c });
|
|
182
|
+
i++;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// Keywords: true / false / null / and / or / not / contains
|
|
186
|
+
const kw = src.slice(i).match(/^[a-zA-Z_][a-zA-Z_0-9]*/);
|
|
187
|
+
if (kw) {
|
|
188
|
+
const word = kw[0];
|
|
189
|
+
i += word.length;
|
|
190
|
+
const lower = word.toLowerCase();
|
|
191
|
+
if (lower === 'true')
|
|
192
|
+
tokens.push({ kind: 'bool', value: true });
|
|
193
|
+
else if (lower === 'false')
|
|
194
|
+
tokens.push({ kind: 'bool', value: false });
|
|
195
|
+
else if (lower === 'null')
|
|
196
|
+
tokens.push({ kind: 'null' });
|
|
197
|
+
else if (lower === 'and')
|
|
198
|
+
tokens.push({ kind: 'op', value: '&&' });
|
|
199
|
+
else if (lower === 'or')
|
|
200
|
+
tokens.push({ kind: 'op', value: '||' });
|
|
201
|
+
else if (lower === 'not')
|
|
202
|
+
tokens.push({ kind: 'op', value: '!' });
|
|
203
|
+
else if (lower === 'contains')
|
|
204
|
+
tokens.push({ kind: 'op', value: 'contains' });
|
|
205
|
+
else
|
|
206
|
+
throw new Error(`unknown identifier "${word}" — refs must be wrapped in {}`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`unexpected character "${c}" at position ${i}`);
|
|
210
|
+
}
|
|
211
|
+
return tokens;
|
|
212
|
+
}
|
|
213
|
+
function prevExpectsBinary(tokens) {
|
|
214
|
+
const prev = tokens[tokens.length - 1];
|
|
215
|
+
if (!prev)
|
|
216
|
+
return false;
|
|
217
|
+
return prev.kind === 'num' || prev.kind === 'str' || prev.kind === 'bool'
|
|
218
|
+
|| prev.kind === 'answer_ref' || prev.kind === 'variable_ref'
|
|
219
|
+
|| prev.kind === 'rparen';
|
|
220
|
+
}
|
|
221
|
+
// ─── Parser — precedence climbing ────────────────────────────────────
|
|
222
|
+
// Operator precedence table — higher number = tighter binding.
|
|
223
|
+
const PREC = {
|
|
224
|
+
'||': 1, '&&': 2,
|
|
225
|
+
'=': 3, '!=': 3, '<>': 3, 'contains': 3,
|
|
226
|
+
'<': 4, '>': 4, '<=': 4, '>=': 4,
|
|
227
|
+
'+': 5, '-': 5,
|
|
228
|
+
'*': 6, '/': 6,
|
|
229
|
+
};
|
|
230
|
+
function parseExpression(tokens, pos, ctx, minPrec = 1) {
|
|
231
|
+
let [lhs, next] = parseUnary(tokens, pos, ctx);
|
|
232
|
+
while (next < tokens.length) {
|
|
233
|
+
const t = tokens[next];
|
|
234
|
+
if (t.kind !== 'op')
|
|
235
|
+
break;
|
|
236
|
+
const prec = PREC[t.value];
|
|
237
|
+
if (prec === undefined || prec < minPrec)
|
|
238
|
+
break;
|
|
239
|
+
const op = t.value;
|
|
240
|
+
const [rhs, after] = parseExpression(tokens, next + 1, ctx, prec + 1);
|
|
241
|
+
lhs = applyBinary(op, lhs, rhs);
|
|
242
|
+
next = after;
|
|
243
|
+
}
|
|
244
|
+
return [lhs, next];
|
|
245
|
+
}
|
|
246
|
+
function parseUnary(tokens, pos, ctx) {
|
|
247
|
+
const t = tokens[pos];
|
|
248
|
+
if (!t)
|
|
249
|
+
throw new Error('unexpected end of expression');
|
|
250
|
+
if (t.kind === 'op' && t.value === '!') {
|
|
251
|
+
const [inner, after] = parseUnary(tokens, pos + 1, ctx);
|
|
252
|
+
return [!truthy(inner), after];
|
|
253
|
+
}
|
|
254
|
+
if (t.kind === 'op' && t.value === '-') {
|
|
255
|
+
const [inner, after] = parseUnary(tokens, pos + 1, ctx);
|
|
256
|
+
return [-Number(inner), after];
|
|
257
|
+
}
|
|
258
|
+
return parsePrimary(tokens, pos, ctx);
|
|
259
|
+
}
|
|
260
|
+
function parsePrimary(tokens, pos, ctx) {
|
|
261
|
+
const t = tokens[pos];
|
|
262
|
+
if (!t)
|
|
263
|
+
throw new Error('unexpected end of expression');
|
|
264
|
+
switch (t.kind) {
|
|
265
|
+
case 'num': return [t.value, pos + 1];
|
|
266
|
+
case 'str': return [t.value, pos + 1];
|
|
267
|
+
case 'bool': return [t.value, pos + 1];
|
|
268
|
+
case 'null': return [null, pos + 1];
|
|
269
|
+
case 'answer_ref': return [ctx.answers[t.name], pos + 1];
|
|
270
|
+
case 'variable_ref': {
|
|
271
|
+
// Resolution rules for {{name.suffix}}:
|
|
272
|
+
// • {{q1.answer}} → ctx.answers.q1 (special-case for legacy)
|
|
273
|
+
// • {{customer_name}} → ctx.variables.customer_name
|
|
274
|
+
// • {{device.theme}} → walk dotted path into ctx.variables.device
|
|
275
|
+
// • {{a.b.c.d}} → walk into ctx.variables.a.b.c.d
|
|
276
|
+
// A dotted suffix that hits a non-object mid-walk returns undefined —
|
|
277
|
+
// which then compares falsy in showWhen. This lets authors reference
|
|
278
|
+
// any structured runtime signal (device, session, session.location, …)
|
|
279
|
+
// without adding new token kinds — they just live under variables.*
|
|
280
|
+
// as nested objects, injected by the SDK at render time.
|
|
281
|
+
if (t.suffix === 'answer')
|
|
282
|
+
return [ctx.answers[t.name], pos + 1];
|
|
283
|
+
const root = ctx.variables[t.name];
|
|
284
|
+
if (!t.suffix)
|
|
285
|
+
return [root, pos + 1];
|
|
286
|
+
let cursor = root;
|
|
287
|
+
for (const seg of t.suffix.split('.')) {
|
|
288
|
+
if (cursor == null || typeof cursor !== 'object') {
|
|
289
|
+
cursor = undefined;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
cursor = cursor[seg];
|
|
293
|
+
}
|
|
294
|
+
return [cursor, pos + 1];
|
|
295
|
+
}
|
|
296
|
+
case 'lparen': {
|
|
297
|
+
const [inner, after] = parseExpression(tokens, pos + 1, ctx, 1);
|
|
298
|
+
if (tokens[after]?.kind !== 'rparen')
|
|
299
|
+
throw new Error('missing closing paren');
|
|
300
|
+
return [inner, after + 1];
|
|
301
|
+
}
|
|
302
|
+
default: throw new Error(`unexpected token: ${JSON.stringify(t)}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// ─── Operators ───────────────────────────────────────────────────────
|
|
306
|
+
function applyBinary(op, lhs, rhs) {
|
|
307
|
+
switch (op) {
|
|
308
|
+
case '=': return String(lhs) === String(rhs);
|
|
309
|
+
case '!=':
|
|
310
|
+
case '<>': return String(lhs) !== String(rhs);
|
|
311
|
+
case '<': return Number(lhs) < Number(rhs);
|
|
312
|
+
case '>': return Number(lhs) > Number(rhs);
|
|
313
|
+
case '<=': return Number(lhs) <= Number(rhs);
|
|
314
|
+
case '>=': return Number(lhs) >= Number(rhs);
|
|
315
|
+
case '&&': return truthy(lhs) && truthy(rhs);
|
|
316
|
+
case '||': return truthy(lhs) || truthy(rhs);
|
|
317
|
+
case '+': return typeof lhs === 'string' || typeof rhs === 'string'
|
|
318
|
+
? String(lhs) + String(rhs)
|
|
319
|
+
: Number(lhs) + Number(rhs);
|
|
320
|
+
case '-': return Number(lhs) - Number(rhs);
|
|
321
|
+
case '*': return Number(lhs) * Number(rhs);
|
|
322
|
+
case '/': return Number(lhs) / Number(rhs);
|
|
323
|
+
case 'contains':
|
|
324
|
+
if (Array.isArray(lhs))
|
|
325
|
+
return lhs.includes(rhs);
|
|
326
|
+
return String(lhs ?? '').includes(String(rhs));
|
|
327
|
+
default: return undefined;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function truthy(v) {
|
|
331
|
+
if (v === false || v === null || v === undefined || v === 0 || v === '')
|
|
332
|
+
return false;
|
|
333
|
+
if (Array.isArray(v))
|
|
334
|
+
return v.length > 0;
|
|
335
|
+
return true;
|
|
336
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cxpinsight/survey-spec
|
|
3
|
+
*
|
|
4
|
+
* Behaviour that must be identical for a respondent regardless of how they
|
|
5
|
+
* arrived — a link, a site running the web SDK, or a mobile app. Three renderers
|
|
6
|
+
* implemented this separately and drifted: the React tokenizer silently hid every
|
|
7
|
+
* question whose condition used `<=`, `>=`, `==`, `<>`, `&&` or `||`, and the web
|
|
8
|
+
* SDK could not resolve any variable namespace except `device`.
|
|
9
|
+
*
|
|
10
|
+
* tools/survey-conformance runs the same fixtures through every renderer and
|
|
11
|
+
* fails on any disagreement.
|
|
12
|
+
*/
|
|
13
|
+
export { evaluateExpression, evaluateCondition, isParseable } from './expressionEval';
|
|
14
|
+
export { resolveVariable } from './resolveVariable';
|
|
15
|
+
export type { AnswersMap, VariablesMap, EvalContext } from './types';
|
|
16
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACtF,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveVariable = exports.isParseable = exports.evaluateCondition = exports.evaluateExpression = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* @cxpinsight/survey-spec
|
|
6
|
+
*
|
|
7
|
+
* Behaviour that must be identical for a respondent regardless of how they
|
|
8
|
+
* arrived — a link, a site running the web SDK, or a mobile app. Three renderers
|
|
9
|
+
* implemented this separately and drifted: the React tokenizer silently hid every
|
|
10
|
+
* question whose condition used `<=`, `>=`, `==`, `<>`, `&&` or `||`, and the web
|
|
11
|
+
* SDK could not resolve any variable namespace except `device`.
|
|
12
|
+
*
|
|
13
|
+
* tools/survey-conformance runs the same fixtures through every renderer and
|
|
14
|
+
* fails on any disagreement.
|
|
15
|
+
*/
|
|
16
|
+
var expressionEval_1 = require("./expressionEval");
|
|
17
|
+
Object.defineProperty(exports, "evaluateExpression", { enumerable: true, get: function () { return expressionEval_1.evaluateExpression; } });
|
|
18
|
+
Object.defineProperty(exports, "evaluateCondition", { enumerable: true, get: function () { return expressionEval_1.evaluateCondition; } });
|
|
19
|
+
Object.defineProperty(exports, "isParseable", { enumerable: true, get: function () { return expressionEval_1.isParseable; } });
|
|
20
|
+
var resolveVariable_1 = require("./resolveVariable");
|
|
21
|
+
Object.defineProperty(exports, "resolveVariable", { enumerable: true, get: function () { return resolveVariable_1.resolveVariable; } });
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One rule for resolving `{{name}}` and `{{name.path}}` against the variable bag.
|
|
3
|
+
*
|
|
4
|
+
* It exists because the same reference meant two different things depending on
|
|
5
|
+
* where it appeared. Conditions walked nested namespaces; text interpolation
|
|
6
|
+
* looked up a flat dotted key and, when that missed, returned the NAMESPACE
|
|
7
|
+
* OBJECT — so a nested bag rendered
|
|
8
|
+
*
|
|
9
|
+
* "Hi {{user.name}}" → Hi {"plan":"pro","name":"Sam","email":"…"}
|
|
10
|
+
*
|
|
11
|
+
* putting every field of that namespace on screen in front of the respondent.
|
|
12
|
+
* An author has no reason to expect two resolution rules for one syntax, so
|
|
13
|
+
* there is now one, and both callers use it.
|
|
14
|
+
*
|
|
15
|
+
* Flat key first, then walk: a bag built the old way keeps resolving unchanged,
|
|
16
|
+
* and a nested bag resolves properly.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveVariable(variables: Record<string, unknown>, name: string, suffix?: string): unknown;
|
|
19
|
+
//# sourceMappingURL=resolveVariable.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolveVariable.d.ts","sourceRoot":"","sources":["../src/resolveVariable.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CA4BT"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveVariable = resolveVariable;
|
|
4
|
+
/**
|
|
5
|
+
* One rule for resolving `{{name}}` and `{{name.path}}` against the variable bag.
|
|
6
|
+
*
|
|
7
|
+
* It exists because the same reference meant two different things depending on
|
|
8
|
+
* where it appeared. Conditions walked nested namespaces; text interpolation
|
|
9
|
+
* looked up a flat dotted key and, when that missed, returned the NAMESPACE
|
|
10
|
+
* OBJECT — so a nested bag rendered
|
|
11
|
+
*
|
|
12
|
+
* "Hi {{user.name}}" → Hi {"plan":"pro","name":"Sam","email":"…"}
|
|
13
|
+
*
|
|
14
|
+
* putting every field of that namespace on screen in front of the respondent.
|
|
15
|
+
* An author has no reason to expect two resolution rules for one syntax, so
|
|
16
|
+
* there is now one, and both callers use it.
|
|
17
|
+
*
|
|
18
|
+
* Flat key first, then walk: a bag built the old way keeps resolving unchanged,
|
|
19
|
+
* and a nested bag resolves properly.
|
|
20
|
+
*/
|
|
21
|
+
function resolveVariable(variables, name, suffix) {
|
|
22
|
+
if (!variables)
|
|
23
|
+
return undefined;
|
|
24
|
+
const full = suffix ? `${name}.${suffix}` : name;
|
|
25
|
+
let value;
|
|
26
|
+
if (Object.prototype.hasOwnProperty.call(variables, full)) {
|
|
27
|
+
// 1. Exact flat key — what bags built before namespaces contained.
|
|
28
|
+
value = variables[full];
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
// 2. Walk the dotted path into nested namespaces.
|
|
32
|
+
const segments = full.split('.');
|
|
33
|
+
let cursor = variables;
|
|
34
|
+
for (const seg of segments) {
|
|
35
|
+
if (cursor == null || typeof cursor !== 'object') {
|
|
36
|
+
cursor = undefined;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
cursor = cursor[seg];
|
|
40
|
+
}
|
|
41
|
+
value = cursor;
|
|
42
|
+
}
|
|
43
|
+
// 3. A namespace is not a value. `{{user}}` with no path resolves to the whole
|
|
44
|
+
// object, and stringifying it puts every field of that bag — plan, email,
|
|
45
|
+
// internal ids — on screen in front of the respondent. Applies to BOTH
|
|
46
|
+
// lookup paths: a flat bag can hold an object under a bare key too.
|
|
47
|
+
// Arrays are values (a multi-select answer), so they pass.
|
|
48
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value))
|
|
49
|
+
return undefined;
|
|
50
|
+
return value;
|
|
51
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Answers keyed by question id, as the renderer has collected them so far. */
|
|
2
|
+
export type AnswersMap = Record<string, unknown>;
|
|
3
|
+
/**
|
|
4
|
+
* The variable environment a condition resolves against.
|
|
5
|
+
*
|
|
6
|
+
* Namespaces are NESTED objects — `variables.device.theme`, `variables.user.plan`
|
|
7
|
+
* — which is what lets an SDK expose structured runtime signals without the
|
|
8
|
+
* expression grammar growing new token kinds. The web SDK used to flatten one
|
|
9
|
+
* namespace by concatenating `'device.'` onto a key, which meant every OTHER
|
|
10
|
+
* namespace silently evaluated false there and true elsewhere.
|
|
11
|
+
*/
|
|
12
|
+
export type VariablesMap = Record<string, unknown>;
|
|
13
|
+
export interface EvalContext {
|
|
14
|
+
variables: VariablesMap;
|
|
15
|
+
answers: AnswersMap;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEjD;;;;;;;;GAQG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEnD,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,YAAY,CAAC;IACxB,OAAO,EAAE,UAAU,CAAC;CACrB"}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cxpinsight/survey-spec",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The survey specification the renderers share — expression evaluation first. Source of truth for behaviour that must be identical on web, links and mobile.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist/",
|
|
9
|
+
"src/",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"test": "node --test test/*.test.js",
|
|
16
|
+
"prepublishOnly": "npm run typecheck && npm run build"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"typescript": ">=5.0.0"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/SaifDalli2/CX_Platform.git",
|
|
28
|
+
"directory": "packages/survey-spec"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { AnswersMap, VariablesMap, EvalContext } from './types';
|
|
2
|
+
import type { EvalContext } from './types';
|
|
3
|
+
export declare function evaluateExpression(source: string, ctx: EvalContext): unknown;
|
|
4
|
+
export declare function evaluateCondition(source: string | undefined, ctx: EvalContext): boolean;
|
|
5
|
+
/** Whether `source` parses at all, independent of what it evaluates to. */
|
|
6
|
+
export declare function isParseable(source: string | undefined): boolean;
|
|
7
|
+
//# sourceMappingURL=expressionEval.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"expressionEval.d.ts","sourceRoot":"","sources":["expressionEval.ts"],"names":[],"mappings":"AAwBA,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AACrE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAI3C,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CAY5E;AAID,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CAcvF;AAED,2EAA2E;AAC3E,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAS/D"}
|