@cxpinsight/survey-spec 0.1.0 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CXPinsight
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.d.ts CHANGED
@@ -12,5 +12,9 @@
12
12
  */
13
13
  export { evaluateExpression, evaluateCondition, isParseable } from './expressionEval';
14
14
  export { resolveVariable } from './resolveVariable';
15
+ export { splitIntoPages } from './pageFlow';
16
+ export { validateAnswer } from './validate';
17
+ export type { ValidatableQuestion } from './validate';
18
+ export type { Page, PageItem, PageFlowOptions, FlowQuestion, FlowSection } from './pageFlow';
15
19
  export type { AnswersMap, VariablesMap, EvalContext } from './types';
16
20
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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"}
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,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,YAAY,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACtD,YAAY,EAAE,IAAI,EAAE,QAAQ,EAAE,eAAe,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC7F,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC"}
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.resolveVariable = exports.isParseable = exports.evaluateCondition = exports.evaluateExpression = void 0;
3
+ exports.validateAnswer = exports.splitIntoPages = exports.resolveVariable = exports.isParseable = exports.evaluateCondition = exports.evaluateExpression = void 0;
4
4
  /**
5
5
  * @cxpinsight/survey-spec
6
6
  *
@@ -19,3 +19,7 @@ Object.defineProperty(exports, "evaluateCondition", { enumerable: true, get: fun
19
19
  Object.defineProperty(exports, "isParseable", { enumerable: true, get: function () { return expressionEval_1.isParseable; } });
20
20
  var resolveVariable_1 = require("./resolveVariable");
21
21
  Object.defineProperty(exports, "resolveVariable", { enumerable: true, get: function () { return resolveVariable_1.resolveVariable; } });
22
+ var pageFlow_1 = require("./pageFlow");
23
+ Object.defineProperty(exports, "splitIntoPages", { enumerable: true, get: function () { return pageFlow_1.splitIntoPages; } });
24
+ var validate_1 = require("./validate");
25
+ Object.defineProperty(exports, "validateAnswer", { enumerable: true, get: function () { return validate_1.validateAnswer; } });
@@ -0,0 +1,49 @@
1
+ import type { EvalContext } from './types';
2
+ export interface FlowQuestion {
3
+ id: string;
4
+ type?: string;
5
+ /** Multi-flow (spec §28). Undefined means the root flow. */
6
+ flow?: string;
7
+ }
8
+ export interface FlowSection {
9
+ id: string;
10
+ questionIds?: string[];
11
+ hidden?: boolean;
12
+ showWhen?: string;
13
+ hideWhen?: string;
14
+ /** true → its own page. Absent/false → an inline heading in the current page. */
15
+ pageBreak?: boolean;
16
+ }
17
+ export type PageItem<Q extends FlowQuestion = FlowQuestion, S extends FlowSection = FlowSection> = {
18
+ kind: 'question';
19
+ question: Q;
20
+ } | {
21
+ kind: 'section-heading';
22
+ section: S;
23
+ };
24
+ export interface Page<Q extends FlowQuestion = FlowQuestion, S extends FlowSection = FlowSection> {
25
+ section?: S;
26
+ items: PageItem<Q, S>[];
27
+ questions: Q[];
28
+ }
29
+ export interface PageFlowOptions {
30
+ /**
31
+ * Multi-flow filter. `undefined` = the root flow (a root respondent never
32
+ * sees fork questions inline); a string = that named flow; `null` bypasses
33
+ * the filter entirely, which mobile uses for cross-flow lookups such as
34
+ * resolving a `rejoinAt` target.
35
+ */
36
+ flow?: string | null;
37
+ presentationMode?: string;
38
+ /**
39
+ * Typeform / conversation only. See the header: this is an open product
40
+ * decision, carried as a parameter so neither channel changes under the
41
+ * other's rule before it is made.
42
+ */
43
+ typeformSections?: 'per-section' | 'per-question';
44
+ }
45
+ export declare function splitIntoPages<Q extends FlowQuestion = FlowQuestion, S extends FlowSection = FlowSection>(survey: {
46
+ questions?: Q[];
47
+ sections?: S[];
48
+ }, ctx: EvalContext, options?: PageFlowOptions): Page<Q, S>[];
49
+ //# sourceMappingURL=pageFlow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pageFlow.d.ts","sourceRoot":"","sources":["../src/pageFlow.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAK3C,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAMD,MAAM,MAAM,QAAQ,CAAC,CAAC,SAAS,YAAY,GAAG,YAAY,EAAE,CAAC,SAAS,WAAW,GAAG,WAAW,IAC3F;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,CAAC,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,OAAO,EAAE,CAAC,CAAA;CAAE,CAAC;AAE5C,MAAM,WAAW,IAAI,CAAC,CAAC,SAAS,YAAY,GAAG,YAAY,EAAE,CAAC,SAAS,WAAW,GAAG,WAAW;IAC9F,OAAO,CAAC,EAAE,CAAC,CAAC;IACZ,KAAK,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;IACxB,SAAS,EAAE,CAAC,EAAE,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,aAAa,GAAG,cAAc,CAAC;CACnD;AAoBD,wBAAgB,cAAc,CAC5B,CAAC,SAAS,YAAY,GAAG,YAAY,EACrC,CAAC,SAAS,WAAW,GAAG,WAAW,EAEnC,MAAM,EAAE;IAAE,SAAS,CAAC,EAAE,CAAC,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAA;CAAE,EAC3C,GAAG,EAAE,WAAW,EAChB,OAAO,GAAE,eAAoB,GAC5B,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAyEd"}
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.splitIntoPages = splitIntoPages;
4
+ /**
5
+ * Page flow — how a flat `questions[]` plus a `sections[]` overlay becomes pages.
6
+ *
7
+ * This was written three times and the implementations disagreed on 6 of 17
8
+ * fixtures (`tools/survey-conformance/pageFlow.js`). The differences were not
9
+ * cosmetic: a survey with a loose question before a section was asked in a
10
+ * DIFFERENT ORDER on React than on mobile, and the answers were recorded as
11
+ * though it were the same instrument.
12
+ *
13
+ * ## What this module settles, and what it deliberately does not
14
+ *
15
+ * Two of the three divergences were bugs and are closed here:
16
+ *
17
+ * 1. **Page order follows QUESTION order.** A section is emitted when the walk
18
+ * first reaches a question that belongs to it. Mobile previously walked
19
+ * `sections[]` and appended unclaimed questions at the end, so section order
20
+ * in the definition — which the builder does not treat as meaningful — could
21
+ * reorder the survey.
22
+ *
23
+ * 2. **Section conditions are evaluated.** `showWhen` / `hideWhen` on a section
24
+ * are honoured, not just `hidden`. Mobile ignored them, so a section
25
+ * conditioned away still split pages there.
26
+ *
27
+ * The third is a product decision, not a defect, so it is a PARAMETER rather
28
+ * than a silent choice: in typeform and conversation modes, does a section
29
+ * become one page, or does each of its questions get its own? React has always
30
+ * done per-section; mobile has always done per-question, and says so in its own
31
+ * comments. Encoding either one here would change page counts on live surveys
32
+ * for whichever channel lost, so `typeformSections` carries the difference in
33
+ * the open until someone decides.
34
+ */
35
+ const expressionEval_1 = require("./expressionEval");
36
+ const isPageBreak = (q) => String(q.type || '').toLowerCase() === 'pagebreak';
37
+ /** Sections that survive `hidden`, `showWhen` and `hideWhen`, keyed for lookup. */
38
+ function activeSections(sections, ctx) {
39
+ const claimed = new Map();
40
+ const active = new Map();
41
+ for (const s of sections) {
42
+ if (s.hidden === true)
43
+ continue;
44
+ if (!(0, expressionEval_1.evaluateCondition)(s.showWhen, ctx))
45
+ continue;
46
+ if (s.hideWhen && (0, expressionEval_1.evaluateCondition)(s.hideWhen, ctx))
47
+ continue;
48
+ for (const id of s.questionIds || [])
49
+ claimed.set(id, s.id);
50
+ active.set(s.id, s);
51
+ }
52
+ // A question claimed by a SKIPPED section is deliberately left unclaimed, so
53
+ // it falls through into whichever page it lands in rather than disappearing.
54
+ return { claimed, active };
55
+ }
56
+ function splitIntoPages(survey, ctx, options = {}) {
57
+ const { flow, presentationMode, typeformSections = 'per-section' } = options;
58
+ const raw = survey.questions || [];
59
+ const all = flow === null
60
+ ? raw
61
+ : raw.filter((q) => (q.flow || undefined) === (flow || undefined));
62
+ const sections = survey.sections || [];
63
+ const { claimed, active } = activeSections(sections, ctx);
64
+ if (presentationMode === 'typeform' || presentationMode === 'conversation') {
65
+ const pages = [];
66
+ const emitted = new Set();
67
+ for (const q of all) {
68
+ if (isPageBreak(q))
69
+ continue;
70
+ const sid = typeformSections === 'per-section' ? claimed.get(q.id) : undefined;
71
+ if (sid) {
72
+ if (emitted.has(sid))
73
+ continue;
74
+ const section = active.get(sid);
75
+ if (!section)
76
+ continue;
77
+ const qs = all.filter((qq) => claimed.get(qq.id) === sid && !isPageBreak(qq));
78
+ pages.push({ section, items: qs.map((qq) => ({ kind: 'question', question: qq })), questions: qs });
79
+ emitted.add(sid);
80
+ }
81
+ else {
82
+ pages.push({ items: [{ kind: 'question', question: q }], questions: [q] });
83
+ }
84
+ }
85
+ return pages;
86
+ }
87
+ const pages = [];
88
+ const emitted = new Set();
89
+ let current = [];
90
+ const flush = () => {
91
+ if (!current.length)
92
+ return;
93
+ pages.push({
94
+ items: current,
95
+ questions: current
96
+ .filter((it) => it.kind === 'question')
97
+ .map((it) => it.question),
98
+ });
99
+ current = [];
100
+ };
101
+ for (const q of all) {
102
+ // A pageBreak splits the current page and never renders itself. One inside
103
+ // a section is ignored — the section already controls its own boundary.
104
+ if (isPageBreak(q)) {
105
+ if (!claimed.has(q.id))
106
+ flush();
107
+ continue;
108
+ }
109
+ const sid = claimed.get(q.id);
110
+ if (!sid) {
111
+ current.push({ kind: 'question', question: q });
112
+ continue;
113
+ }
114
+ if (emitted.has(sid))
115
+ continue;
116
+ const section = active.get(sid);
117
+ if (!section)
118
+ continue;
119
+ const qs = all.filter((qq) => claimed.get(qq.id) === sid && !isPageBreak(qq));
120
+ if (section.pageBreak === true) {
121
+ flush();
122
+ pages.push({ section, items: qs.map((qq) => ({ kind: 'question', question: qq })), questions: qs });
123
+ }
124
+ else {
125
+ current.push({ kind: 'section-heading', section });
126
+ for (const qq of qs)
127
+ current.push({ kind: 'question', question: qq });
128
+ }
129
+ emitted.add(sid);
130
+ }
131
+ flush();
132
+ return pages;
133
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Answer validation — whether a respondent may submit.
3
+ *
4
+ * A divergence here means the same answer is accepted on one channel and
5
+ * rejected on another. The respondent hits a wall that does not exist for
6
+ * someone who arrived by a different route, and an analyst comparing the two
7
+ * sets of responses is comparing two instruments.
8
+ *
9
+ * There were three implementations and all three disagreed:
10
+ *
11
+ * • The WEB SDK had none. `survey.js` checked emptiness inline at the Next
12
+ * button and nothing else, so it accepted 8 of 32 answers the others
13
+ * rejected — including whitespace against a required question, `nope` as an
14
+ * email address, and any string against `minLength`.
15
+ *
16
+ * • REACT and MOBILE agreed on 26 fixtures and still differed, because every
17
+ * one of those fixtures used `type: 'text'` with an `inputType`. React's
18
+ * dispatcher has no case for `email`, `number`, `csat` or `ces` as question
19
+ * TYPES, so a question of `type: 'email'` was format-checked on the phone
20
+ * and waved through on a link.
21
+ *
22
+ * This module is the union, and each half was chosen for a reason:
23
+ *
24
+ * • `isEmpty` is React's, because it is type-aware. The web renderer's values
25
+ * are compound — `{ choice }`, `{ choices }`, `{ text, attachments }` — and
26
+ * mobile's primitives pass through the same code unharmed. Mobile's
27
+ * primitive-only version would have called `{ choice: undefined }` non-empty
28
+ * and let a blank required radio through.
29
+ *
30
+ * • The type dispatcher is mobile's, because it is the superset, and the
31
+ * effective format is derived from `inputType || type` so `type: 'email'`
32
+ * validates as an email whether or not the author also set an inputType.
33
+ *
34
+ * Order is spec §8 and it matters: required, then type-specific format, then
35
+ * the author's `rules[]`. An empty answer should read "this is required", not
36
+ * "that is not a valid email" — which sounds like the shape is wrong rather
37
+ * than the field being blank.
38
+ */
39
+ export interface ValidatableQuestion {
40
+ type?: string;
41
+ inputType?: string;
42
+ required?: boolean;
43
+ isRequired?: boolean;
44
+ minLength?: number;
45
+ maxLength?: number;
46
+ minValue?: number;
47
+ maxValue?: number;
48
+ minCount?: number;
49
+ maxCount?: number;
50
+ rules?: unknown;
51
+ }
52
+ /** Null when the answer is valid; otherwise the message to show. */
53
+ export declare function validateAnswer(question: ValidatableQuestion, value: unknown): string | null;
54
+ //# sourceMappingURL=validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAYH,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,oEAAoE;AACpE,wBAAgB,cAAc,CAAC,QAAQ,EAAE,mBAAmB,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAgB3F"}
@@ -0,0 +1,243 @@
1
+ "use strict";
2
+ /**
3
+ * Answer validation — whether a respondent may submit.
4
+ *
5
+ * A divergence here means the same answer is accepted on one channel and
6
+ * rejected on another. The respondent hits a wall that does not exist for
7
+ * someone who arrived by a different route, and an analyst comparing the two
8
+ * sets of responses is comparing two instruments.
9
+ *
10
+ * There were three implementations and all three disagreed:
11
+ *
12
+ * • The WEB SDK had none. `survey.js` checked emptiness inline at the Next
13
+ * button and nothing else, so it accepted 8 of 32 answers the others
14
+ * rejected — including whitespace against a required question, `nope` as an
15
+ * email address, and any string against `minLength`.
16
+ *
17
+ * • REACT and MOBILE agreed on 26 fixtures and still differed, because every
18
+ * one of those fixtures used `type: 'text'` with an `inputType`. React's
19
+ * dispatcher has no case for `email`, `number`, `csat` or `ces` as question
20
+ * TYPES, so a question of `type: 'email'` was format-checked on the phone
21
+ * and waved through on a link.
22
+ *
23
+ * This module is the union, and each half was chosen for a reason:
24
+ *
25
+ * • `isEmpty` is React's, because it is type-aware. The web renderer's values
26
+ * are compound — `{ choice }`, `{ choices }`, `{ text, attachments }` — and
27
+ * mobile's primitives pass through the same code unharmed. Mobile's
28
+ * primitive-only version would have called `{ choice: undefined }` non-empty
29
+ * and let a blank required radio through.
30
+ *
31
+ * • The type dispatcher is mobile's, because it is the superset, and the
32
+ * effective format is derived from `inputType || type` so `type: 'email'`
33
+ * validates as an email whether or not the author also set an inputType.
34
+ *
35
+ * Order is spec §8 and it matters: required, then type-specific format, then
36
+ * the author's `rules[]`. An empty answer should read "this is required", not
37
+ * "that is not a valid email" — which sounds like the shape is wrong rather
38
+ * than the field being blank.
39
+ */
40
+ Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports.validateAnswer = validateAnswer;
42
+ /**
43
+ * Practical email format — matches what the HTML5 email validator accepts.
44
+ * Not RFC 5322-strict: that regex is a page long and rejects addresses real
45
+ * mail servers deliver to every day.
46
+ */
47
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
48
+ /** URL with a scheme. No guessing at whether `http://` was meant. */
49
+ const URL_RE = /^https?:\/\/[\w.\-]+(?:\.[\w.\-]+)+(?:[/?#][^\s]*)?$/i;
50
+ /** Null when the answer is valid; otherwise the message to show. */
51
+ function validateAnswer(question, value) {
52
+ // BOTH spellings. The v1 vocabulary and the builder both emit `isRequired`,
53
+ // and a renderer reading only `required` let a required question be submitted
54
+ // blank while the other channels rejected it.
55
+ const empty = isEmpty(question, value);
56
+ if ((question.required || question.isRequired) && empty) {
57
+ return 'This field is required';
58
+ }
59
+ // An optional field left blank is valid. Complaining about the shape of an
60
+ // answer nobody gave is noise.
61
+ if (empty)
62
+ return null;
63
+ const typeErr = validateByType(question, value);
64
+ if (typeErr)
65
+ return typeErr;
66
+ return validateRules(question, value);
67
+ }
68
+ /**
69
+ * Emptiness, per value shape.
70
+ *
71
+ * `0` and `false` are ANSWERS. A rating of zero and a "no" on a yes/no question
72
+ * are both real responses, and treating them as blank rejects a respondent who
73
+ * answered.
74
+ */
75
+ function isEmpty(question, value) {
76
+ if (value === undefined || value === null)
77
+ return true;
78
+ if (typeof value === 'string')
79
+ return value.trim() === '';
80
+ if (typeof value === 'number')
81
+ return Number.isNaN(value);
82
+ if (typeof value === 'boolean')
83
+ return false;
84
+ if (Array.isArray(value))
85
+ return value.length === 0;
86
+ if (typeof value === 'object') {
87
+ const v = value;
88
+ if (question.type === 'radio')
89
+ return !v.choice;
90
+ if (question.type === 'checkbox') {
91
+ return !Array.isArray(v.choices) || v.choices.length === 0;
92
+ }
93
+ if (question.type === 'multiText') {
94
+ return Object.values(v).every((x) => x === undefined || x === null || (typeof x === 'string' && x.trim() === ''));
95
+ }
96
+ if (question.type === 'text' || question.type === 'comment') {
97
+ // allowAttachments makes this { text, attachments }. Empty means both:
98
+ // a respondent who attached a file and typed nothing has answered.
99
+ const textEmpty = typeof v.text !== 'string' || v.text.trim() === '';
100
+ const noFiles = !Array.isArray(v.attachments) || v.attachments.length === 0;
101
+ return textEmpty && noFiles;
102
+ }
103
+ // matrixDropdown / matrixDynamic / panelDynamic / signature — any content
104
+ // counts as answered.
105
+ return Object.keys(v).length === 0;
106
+ }
107
+ return false;
108
+ }
109
+ function validateByType(question, value) {
110
+ switch (question.type) {
111
+ case 'text':
112
+ case 'comment':
113
+ case 'email':
114
+ case 'number':
115
+ return validateTextish(question, value);
116
+ case 'checkbox':
117
+ return validateCheckbox(question, value);
118
+ case 'rating':
119
+ case 'csat':
120
+ case 'ces':
121
+ case 'nps':
122
+ return validateNumericRange(question, value);
123
+ default:
124
+ return null;
125
+ }
126
+ }
127
+ function validateTextish(question, value) {
128
+ // Unwrap the compound { text, attachments } shape the web renderer uses.
129
+ let raw;
130
+ if (typeof value === 'string') {
131
+ raw = value;
132
+ }
133
+ else if (value && typeof value === 'object' && 'text' in value) {
134
+ const t = value.text;
135
+ raw = typeof t === 'string' ? t : '';
136
+ }
137
+ else {
138
+ raw = String(value ?? '');
139
+ }
140
+ const trimmed = raw.trim();
141
+ // An author who picks the "Email" question type has declared the format just
142
+ // as much as one who sets inputType on a text question.
143
+ const inputType = question.inputType
144
+ || (question.type === 'email' ? 'email' : question.type === 'number' ? 'number' : undefined);
145
+ if (inputType === 'email' && !EMAIL_RE.test(trimmed)) {
146
+ return 'Please enter a valid email address (e.g., you@example.com)';
147
+ }
148
+ if (inputType === 'url' && !URL_RE.test(trimmed)) {
149
+ return 'Please enter a full URL, including https://';
150
+ }
151
+ if (inputType === 'number' && Number.isNaN(Number(trimmed))) {
152
+ return 'Please enter a number';
153
+ }
154
+ if (inputType === 'tel' && !/^[\d\s+\-()]{3,}$/.test(trimmed)) {
155
+ // Deliberately permissive. Country-specific phone validation inline is a
156
+ // losing game that mostly rejects real numbers.
157
+ return 'Please enter a valid phone number';
158
+ }
159
+ if (typeof question.maxLength === 'number' && trimmed.length > question.maxLength) {
160
+ return `Please keep this under ${question.maxLength} characters`;
161
+ }
162
+ if (typeof question.minLength === 'number' && trimmed.length < question.minLength) {
163
+ return `Please enter at least ${question.minLength} characters`;
164
+ }
165
+ return null;
166
+ }
167
+ function validateCheckbox(question, value) {
168
+ // Web sends { choices }, mobile sends an array. Both are the same answer.
169
+ const choices = Array.isArray(value)
170
+ ? value
171
+ : (value && typeof value === 'object' && Array.isArray(value.choices)
172
+ ? value.choices
173
+ : []);
174
+ const count = choices.length;
175
+ if (typeof question.minCount === 'number' && count < question.minCount) {
176
+ return `Please select at least ${question.minCount}`;
177
+ }
178
+ if (typeof question.maxCount === 'number' && count > question.maxCount) {
179
+ return `Please select at most ${question.maxCount}`;
180
+ }
181
+ return null;
182
+ }
183
+ function validateNumericRange(question, value) {
184
+ if (typeof value !== 'number')
185
+ return null;
186
+ if (typeof question.minValue === 'number' && value < question.minValue) {
187
+ return `Must be at least ${question.minValue}`;
188
+ }
189
+ if (typeof question.maxValue === 'number' && value > question.maxValue) {
190
+ return `Must be at most ${question.maxValue}`;
191
+ }
192
+ return null;
193
+ }
194
+ function validateRules(question, value) {
195
+ const rules = Array.isArray(question.rules) ? question.rules : [];
196
+ if (rules.length === 0)
197
+ return null;
198
+ const str = typeof value === 'string' ? value : String(value ?? '');
199
+ for (const r of rules) {
200
+ const err = applyRule(r, str);
201
+ // The author's own message wins when they wrote one.
202
+ if (err)
203
+ return r.message || err;
204
+ }
205
+ return null;
206
+ }
207
+ function applyRule(rule, str) {
208
+ switch (rule.type) {
209
+ case 'minLength':
210
+ return typeof rule.value === 'number' && str.length < rule.value
211
+ ? `At least ${rule.value} characters` : null;
212
+ case 'maxLength':
213
+ return typeof rule.value === 'number' && str.length > rule.value
214
+ ? `At most ${rule.value} characters` : null;
215
+ case 'min':
216
+ return typeof rule.value === 'number' && Number(str) < rule.value
217
+ ? `Must be at least ${rule.value}` : null;
218
+ case 'max':
219
+ return typeof rule.value === 'number' && Number(str) > rule.value
220
+ ? `Must be at most ${rule.value}` : null;
221
+ case 'email':
222
+ return EMAIL_RE.test(str) ? null : 'Please enter a valid email address';
223
+ case 'url':
224
+ return URL_RE.test(str) ? null : 'Please enter a valid URL';
225
+ case 'pattern': {
226
+ if (typeof rule.value !== 'string')
227
+ return null;
228
+ try {
229
+ // An unparseable pattern is an authoring mistake. Failing OPEN is
230
+ // deliberate: blocking every respondent on a survey because of a typo
231
+ // in a regex is worse than letting the answer through.
232
+ return new RegExp(rule.value).test(str) ? null : "Format doesn't match";
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ }
238
+ default:
239
+ // Unknown rule types skip rather than block — the spec can add one
240
+ // before every renderer knows about it.
241
+ return null;
242
+ }
243
+ }
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@cxpinsight/survey-spec",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "files": [
8
8
  "dist/",
9
- "src/",
10
- "README.md"
9
+ "README.md",
10
+ "LICENSE"
11
11
  ],
12
12
  "scripts": {
13
13
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
14
14
  "typecheck": "tsc --noEmit",
15
15
  "test": "node --test test/*.test.js",
16
- "prepublishOnly": "npm run typecheck && npm run build"
16
+ "prepublishOnly": "node scripts/check-toolchain.js && npm run typecheck && npm run build"
17
17
  },
18
18
  "devDependencies": {
19
19
  "typescript": ">=5.0.0"
@@ -1,7 +0,0 @@
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
@@ -1 +0,0 @@
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"}