@cxpinsight/survey-spec 0.1.1 → 0.3.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/dist/index.d.ts CHANGED
@@ -12,5 +12,11 @@
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';
19
+ export { normalizeTranscript, matchBoolean, matchOption, matchOptions, rankOptions, matchNumberInRange, matchDate, distinctiveLabels, voiceSupport, voiceCanHandle, auditVoiceQuestions, voiceDisclaimer, skipNoteText, scaffoldFor, buildPromptText, shortCode, optionLabelOf, optionValueOf, voiceOptionsOf, optionLabelsOf, } from './voice';
20
+ export type { VoiceSupport, VoiceQuestion, VoiceOption, VoiceScaffold } from './voice';
15
21
  export type { AnswersMap, VariablesMap, EvalContext } from './types';
16
22
  //# 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,OAAO,EACL,mBAAmB,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EACzE,kBAAkB,EAAE,SAAS,EAAE,iBAAiB,EAChD,YAAY,EAAE,cAAc,EAAE,mBAAmB,EACjD,eAAe,EAAE,YAAY,EAAE,WAAW,EAAE,eAAe,EAAE,SAAS,EACtE,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,cAAc,GAC7D,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACvF,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.optionLabelsOf = exports.voiceOptionsOf = exports.optionValueOf = exports.optionLabelOf = exports.shortCode = exports.buildPromptText = exports.scaffoldFor = exports.skipNoteText = exports.voiceDisclaimer = exports.auditVoiceQuestions = exports.voiceCanHandle = exports.voiceSupport = exports.distinctiveLabels = exports.matchDate = exports.matchNumberInRange = exports.rankOptions = exports.matchOptions = exports.matchOption = exports.matchBoolean = exports.normalizeTranscript = 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,28 @@ 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; } });
26
+ var voice_1 = require("./voice");
27
+ Object.defineProperty(exports, "normalizeTranscript", { enumerable: true, get: function () { return voice_1.normalizeTranscript; } });
28
+ Object.defineProperty(exports, "matchBoolean", { enumerable: true, get: function () { return voice_1.matchBoolean; } });
29
+ Object.defineProperty(exports, "matchOption", { enumerable: true, get: function () { return voice_1.matchOption; } });
30
+ Object.defineProperty(exports, "matchOptions", { enumerable: true, get: function () { return voice_1.matchOptions; } });
31
+ Object.defineProperty(exports, "rankOptions", { enumerable: true, get: function () { return voice_1.rankOptions; } });
32
+ Object.defineProperty(exports, "matchNumberInRange", { enumerable: true, get: function () { return voice_1.matchNumberInRange; } });
33
+ Object.defineProperty(exports, "matchDate", { enumerable: true, get: function () { return voice_1.matchDate; } });
34
+ Object.defineProperty(exports, "distinctiveLabels", { enumerable: true, get: function () { return voice_1.distinctiveLabels; } });
35
+ Object.defineProperty(exports, "voiceSupport", { enumerable: true, get: function () { return voice_1.voiceSupport; } });
36
+ Object.defineProperty(exports, "voiceCanHandle", { enumerable: true, get: function () { return voice_1.voiceCanHandle; } });
37
+ Object.defineProperty(exports, "auditVoiceQuestions", { enumerable: true, get: function () { return voice_1.auditVoiceQuestions; } });
38
+ Object.defineProperty(exports, "voiceDisclaimer", { enumerable: true, get: function () { return voice_1.voiceDisclaimer; } });
39
+ Object.defineProperty(exports, "skipNoteText", { enumerable: true, get: function () { return voice_1.skipNoteText; } });
40
+ Object.defineProperty(exports, "scaffoldFor", { enumerable: true, get: function () { return voice_1.scaffoldFor; } });
41
+ Object.defineProperty(exports, "buildPromptText", { enumerable: true, get: function () { return voice_1.buildPromptText; } });
42
+ Object.defineProperty(exports, "shortCode", { enumerable: true, get: function () { return voice_1.shortCode; } });
43
+ Object.defineProperty(exports, "optionLabelOf", { enumerable: true, get: function () { return voice_1.optionLabelOf; } });
44
+ Object.defineProperty(exports, "optionValueOf", { enumerable: true, get: function () { return voice_1.optionValueOf; } });
45
+ Object.defineProperty(exports, "voiceOptionsOf", { enumerable: true, get: function () { return voice_1.voiceOptionsOf; } });
46
+ Object.defineProperty(exports, "optionLabelsOf", { enumerable: true, get: function () { return voice_1.optionLabelsOf; } });
@@ -0,0 +1,43 @@
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
+ export declare function splitIntoPages<Q extends FlowQuestion = FlowQuestion, S extends FlowSection = FlowSection>(survey: {
40
+ questions?: Q[];
41
+ sections?: S[];
42
+ }, ctx: EvalContext, options?: PageFlowOptions): Page<Q, S>[];
43
+ //# sourceMappingURL=pageFlow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pageFlow.d.ts","sourceRoot":"","sources":["../src/pageFlow.ts"],"names":[],"mappings":"AAwCA,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;CAC3B;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,CA+Dd"}
@@ -0,0 +1,127 @@
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 was a product decision rather than a defect, and it has been made:
28
+ * in typeform and conversation modes each question gets its own page, and a
29
+ * section does not group them. That is mobile's long-standing behaviour and
30
+ * matches what the builder does — it hides the add-page control in these modes
31
+ * and turns section headers into intro pages, so the author is already working
32
+ * one question at a time.
33
+ *
34
+ * It carried a `typeformSections` parameter while the question was open. The
35
+ * parameter is gone now the answer exists: a configurable behaviour nobody
36
+ * configures is a second code path that only gets exercised by accident.
37
+ *
38
+ * REACT CHANGES because of this. A typeform survey whose questions are grouped
39
+ * into sections previously showed one page per section there and now shows one
40
+ * per question, so its page count goes up and its progress bar moves in smaller
41
+ * steps. Mobile is unaffected.
42
+ */
43
+ const expressionEval_1 = require("./expressionEval");
44
+ const isPageBreak = (q) => String(q.type || '').toLowerCase() === 'pagebreak';
45
+ /** Sections that survive `hidden`, `showWhen` and `hideWhen`, keyed for lookup. */
46
+ function activeSections(sections, ctx) {
47
+ const claimed = new Map();
48
+ const active = new Map();
49
+ for (const s of sections) {
50
+ if (s.hidden === true)
51
+ continue;
52
+ if (!(0, expressionEval_1.evaluateCondition)(s.showWhen, ctx))
53
+ continue;
54
+ if (s.hideWhen && (0, expressionEval_1.evaluateCondition)(s.hideWhen, ctx))
55
+ continue;
56
+ for (const id of s.questionIds || [])
57
+ claimed.set(id, s.id);
58
+ active.set(s.id, s);
59
+ }
60
+ // A question claimed by a SKIPPED section is deliberately left unclaimed, so
61
+ // it falls through into whichever page it lands in rather than disappearing.
62
+ return { claimed, active };
63
+ }
64
+ function splitIntoPages(survey, ctx, options = {}) {
65
+ const { flow, presentationMode } = options;
66
+ const raw = survey.questions || [];
67
+ const all = flow === null
68
+ ? raw
69
+ : raw.filter((q) => (q.flow || undefined) === (flow || undefined));
70
+ const sections = survey.sections || [];
71
+ const { claimed, active } = activeSections(sections, ctx);
72
+ // One question per page. Sections do not group in these modes — see the
73
+ // header. `claimed` is still computed above because section conditions can
74
+ // hide a question's section, and a question whose section is skipped still
75
+ // renders; it just gets its own page like every other.
76
+ if (presentationMode === 'typeform' || presentationMode === 'conversation') {
77
+ return all
78
+ .filter((q) => !isPageBreak(q))
79
+ .map((q) => ({ items: [{ kind: 'question', question: q }], questions: [q] }));
80
+ }
81
+ const pages = [];
82
+ const emitted = new Set();
83
+ let current = [];
84
+ const flush = () => {
85
+ if (!current.length)
86
+ return;
87
+ pages.push({
88
+ items: current,
89
+ questions: current
90
+ .filter((it) => it.kind === 'question')
91
+ .map((it) => it.question),
92
+ });
93
+ current = [];
94
+ };
95
+ for (const q of all) {
96
+ // A pageBreak splits the current page and never renders itself. One inside
97
+ // a section is ignored — the section already controls its own boundary.
98
+ if (isPageBreak(q)) {
99
+ if (!claimed.has(q.id))
100
+ flush();
101
+ continue;
102
+ }
103
+ const sid = claimed.get(q.id);
104
+ if (!sid) {
105
+ current.push({ kind: 'question', question: q });
106
+ continue;
107
+ }
108
+ if (emitted.has(sid))
109
+ continue;
110
+ const section = active.get(sid);
111
+ if (!section)
112
+ continue;
113
+ const qs = all.filter((qq) => claimed.get(qq.id) === sid && !isPageBreak(qq));
114
+ if (section.pageBreak === true) {
115
+ flush();
116
+ pages.push({ section, items: qs.map((qq) => ({ kind: 'question', question: qq })), questions: qs });
117
+ }
118
+ else {
119
+ current.push({ kind: 'section-heading', section });
120
+ for (const qq of qs)
121
+ current.push({ kind: 'question', question: qq });
122
+ }
123
+ emitted.add(sid);
124
+ }
125
+ flush();
126
+ return pages;
127
+ }
@@ -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
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Turning speech into an answer.
3
+ *
4
+ * This is the point where a respondent's VOICE becomes stored data, and a
5
+ * divergence between channels is invisible afterwards: the row looks fine, it
6
+ * is just a different value than the same sentence produced somewhere else.
7
+ * Three implementations existed — the React renderer's handsFreeVoice.tsx, the
8
+ * web SDK's voice.js (ported from it) and the mobile renderer's
9
+ * voiceParsers.ts (an older, stricter copy). Mobile had drifted: no fuzzy
10
+ * tolerance, no filler stripping, no letter shortcuts, no Arabic orthographic
11
+ * folding, English-only ordinals. "grean" for Green resolved on a link and on
12
+ * a website and failed on the phone, which is the channel where hands-free
13
+ * matters most.
14
+ *
15
+ * ── The seam ─────────────────────────────────────────────────────────────
16
+ *
17
+ * These functions return an option INDEX, never an answer. That is deliberate.
18
+ * The three renderers genuinely disagree about answer shape and are each right
19
+ * about their own: React's radio widget stores `{ choice: value }` and
20
+ * normalises it at submit, web and mobile store the bare value. A shared
21
+ * function returning "the answer" would have to pick one and break two.
22
+ * Matching a sentence to an option is the part that must be identical;
23
+ * wrapping an index in a shape is the part that must not be.
24
+ *
25
+ * tools/survey-conformance/voice.js runs the same spoken answers through all
26
+ * three and fails on any disagreement.
27
+ */
28
+ export declare function shortCode(lang?: string): string;
29
+ /** Lower-case, fold Arabic orthography, strip punctuation, normalise spelling. */
30
+ export declare function normalizeTranscript(s: unknown): string;
31
+ /**
32
+ * Strip the prefix every option shares, so "Option A / Option B / Option C"
33
+ * reduces to "a" / "b" / "c" and a respondent who just says "C" is understood.
34
+ */
35
+ export declare function distinctiveLabels(labels: string[]): string[];
36
+ export interface VoiceOption {
37
+ value?: unknown;
38
+ label?: string;
39
+ text?: string;
40
+ alt?: string;
41
+ }
42
+ export interface VoiceQuestion {
43
+ type?: string;
44
+ title?: string;
45
+ text?: string;
46
+ label?: string;
47
+ options?: unknown;
48
+ choices?: unknown;
49
+ yesLabel?: string;
50
+ noLabel?: string;
51
+ minValue?: number;
52
+ maxValue?: number;
53
+ minLabel?: string;
54
+ maxLabel?: string;
55
+ isRequired?: boolean;
56
+ required?: boolean;
57
+ }
58
+ export declare function optionLabelOf(o: VoiceOption): string;
59
+ export declare function optionValueOf(o: VoiceOption): unknown;
60
+ export declare function voiceOptionsOf(q: VoiceQuestion): VoiceOption[];
61
+ export declare function optionLabelsOf(q: VoiceQuestion): string[];
62
+ /** Yes/no, including the question's own yes/no labels. `null` = neither. */
63
+ export declare function matchBoolean(q: VoiceQuestion, transcript: string): boolean | null;
64
+ /** Single choice. Returns the option index, or -1 when nothing matched. */
65
+ export declare function matchOption(q: VoiceQuestion, transcript: string): number;
66
+ /** Multi-choice. Returns ascending option indices; empty when nothing matched. */
67
+ export declare function matchOptions(q: VoiceQuestion, transcript: string): number[];
68
+ /**
69
+ * Ranking. Returns option indices in the order they were spoken, or an empty
70
+ * array when fewer than two were heard — one name is not a ranking.
71
+ */
72
+ export declare function rankOptions(q: VoiceQuestion, transcript: string): number[];
73
+ /** A number inside [min, max], from digits, number words, or a homophone. */
74
+ export declare function matchNumberInRange(transcript: string, min: number, max: number): number | null;
75
+ /** A spoken date as ISO `YYYY-MM-DD`, or null. */
76
+ export declare function matchDate(transcript: string): string | null;
77
+ /**
78
+ * What a hands-free session can do with a question type.
79
+ *
80
+ * full — speak the scaffold, capture, parse into a value.
81
+ * matrix — walk it row by row; each row is a single choice.
82
+ * read — display-only. Read it and advance; there is nothing to capture.
83
+ * assist — read it aloud, then hand off to the visual UI. The respondent can
84
+ * answer, just not by speaking.
85
+ * none — voice cannot produce this answer at all (a signature, a file).
86
+ *
87
+ * `assist` and `none` are one bucket to web and mobile, which collapse both to
88
+ * 'partial'. They are separate here because React distinguishes them and the
89
+ * spec should not force a renderer to lose a distinction it makes.
90
+ */
91
+ export type VoiceSupport = 'full' | 'matrix' | 'read' | 'assist' | 'none';
92
+ export declare function voiceSupport(type: string): VoiceSupport;
93
+ /** Can a voice-only session get through this question unaided? */
94
+ export declare function voiceCanHandle(type: string): boolean;
95
+ /**
96
+ * blocking = required questions voice can't handle → the session must not start.
97
+ * skippable = optional ones → announced and skipped.
98
+ */
99
+ export declare function auditVoiceQuestions<Q extends VoiceQuestion>(questions: Q[]): {
100
+ blocking: Q[];
101
+ skippable: Q[];
102
+ };
103
+ export interface VoiceScaffold {
104
+ scale: (min: number, max: number, minL?: string, maxL?: string) => string;
105
+ binary: (y: string, n: string) => string;
106
+ options: (o: string[]) => string;
107
+ multi: (o: string[]) => string;
108
+ ranking: (o: string[]) => string;
109
+ date: () => string;
110
+ matrixStem: (stem: string) => string;
111
+ matrixRow: (row: string, cols: string[]) => string;
112
+ }
113
+ export declare function scaffoldFor(lang?: string): VoiceScaffold;
114
+ export declare function voiceDisclaimer(n: number, lang?: string): string;
115
+ export declare function skipNoteText(lang?: string): string;
116
+ /** What the session says out loud to ask this question. */
117
+ export declare function buildPromptText(q: VoiceQuestion, lang?: string): string;
118
+ //# sourceMappingURL=voice.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice.d.ts","sourceRoot":"","sources":["../src/voice.ts"],"names":[],"mappings":"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"}
package/dist/voice.js ADDED
@@ -0,0 +1,632 @@
1
+ "use strict";
2
+ /**
3
+ * Turning speech into an answer.
4
+ *
5
+ * This is the point where a respondent's VOICE becomes stored data, and a
6
+ * divergence between channels is invisible afterwards: the row looks fine, it
7
+ * is just a different value than the same sentence produced somewhere else.
8
+ * Three implementations existed — the React renderer's handsFreeVoice.tsx, the
9
+ * web SDK's voice.js (ported from it) and the mobile renderer's
10
+ * voiceParsers.ts (an older, stricter copy). Mobile had drifted: no fuzzy
11
+ * tolerance, no filler stripping, no letter shortcuts, no Arabic orthographic
12
+ * folding, English-only ordinals. "grean" for Green resolved on a link and on
13
+ * a website and failed on the phone, which is the channel where hands-free
14
+ * matters most.
15
+ *
16
+ * ── The seam ─────────────────────────────────────────────────────────────
17
+ *
18
+ * These functions return an option INDEX, never an answer. That is deliberate.
19
+ * The three renderers genuinely disagree about answer shape and are each right
20
+ * about their own: React's radio widget stores `{ choice: value }` and
21
+ * normalises it at submit, web and mobile store the bare value. A shared
22
+ * function returning "the answer" would have to pick one and break two.
23
+ * Matching a sentence to an option is the part that must be identical;
24
+ * wrapping an index in a shape is the part that must not be.
25
+ *
26
+ * tools/survey-conformance/voice.js runs the same spoken answers through all
27
+ * three and fails on any disagreement.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.shortCode = shortCode;
31
+ exports.normalizeTranscript = normalizeTranscript;
32
+ exports.distinctiveLabels = distinctiveLabels;
33
+ exports.optionLabelOf = optionLabelOf;
34
+ exports.optionValueOf = optionValueOf;
35
+ exports.voiceOptionsOf = voiceOptionsOf;
36
+ exports.optionLabelsOf = optionLabelsOf;
37
+ exports.matchBoolean = matchBoolean;
38
+ exports.matchOption = matchOption;
39
+ exports.matchOptions = matchOptions;
40
+ exports.rankOptions = rankOptions;
41
+ exports.matchNumberInRange = matchNumberInRange;
42
+ exports.matchDate = matchDate;
43
+ exports.voiceSupport = voiceSupport;
44
+ exports.voiceCanHandle = voiceCanHandle;
45
+ exports.auditVoiceQuestions = auditVoiceQuestions;
46
+ exports.scaffoldFor = scaffoldFor;
47
+ exports.voiceDisclaimer = voiceDisclaimer;
48
+ exports.skipNoteText = skipNoteText;
49
+ exports.buildPromptText = buildPromptText;
50
+ /* ── Locale ─────────────────────────────────────────────────────────────── */
51
+ function shortCode(lang) {
52
+ return String(lang || 'en').split('-')[0].toLowerCase();
53
+ }
54
+ /* ── Transcript normalization ───────────────────────────────────────────── */
55
+ // Contractions expand BEFORE punctuation is stripped: otherwise "I'd say C"
56
+ // loses the apostrophe and leaves a bare "d" token, which the letter shortcut
57
+ // reads as option D — a silently wrong answer.
58
+ const CONTRACTIONS = [
59
+ [/\bcan['’]t\b/g, 'can not'], [/\bwon['’]t\b/g, 'will not'],
60
+ [/n['’]t\b/g, ' not'], [/['’]ll\b/g, ' will'], [/['’]re\b/g, ' are'],
61
+ [/['’]ve\b/g, ' have'], [/['’]m\b/g, ' am'], [/['’]d\b/g, ' would'],
62
+ [/['’]s\b/g, ''],
63
+ ];
64
+ // Whole-word pairs, never suffix regexes. A rule like -our→-or also rewrites
65
+ // "four"→"for" and corrupts the number table; -re→-er turns "more" into
66
+ // "moer". Boring beats clever here.
67
+ const EN_SPELLING = {
68
+ colour: 'color', colours: 'colors', coloured: 'colored',
69
+ favour: 'favor', favours: 'favors', favourite: 'favorite', favourites: 'favorites',
70
+ flavour: 'flavor', flavours: 'flavors',
71
+ honour: 'honor', humour: 'humor', labour: 'labor',
72
+ neighbour: 'neighbor', neighbours: 'neighbors',
73
+ behaviour: 'behavior', behaviours: 'behaviors',
74
+ harbour: 'harbor', rumour: 'rumor', odour: 'odor',
75
+ armour: 'armor', vapour: 'vapor', endeavour: 'endeavor',
76
+ centre: 'center', centres: 'centers', theatre: 'theater',
77
+ metre: 'meter', metres: 'meters', litre: 'liter', litres: 'liters',
78
+ fibre: 'fiber', calibre: 'caliber',
79
+ organisation: 'organization', organisations: 'organizations',
80
+ organise: 'organize', organised: 'organized',
81
+ realise: 'realize', realised: 'realized',
82
+ recognise: 'recognize', recognised: 'recognized',
83
+ apologise: 'apologize', apologised: 'apologized',
84
+ personalise: 'personalize', personalised: 'personalized',
85
+ customise: 'customize', customised: 'customized',
86
+ analyse: 'analyze', analysed: 'analyzed',
87
+ catalogue: 'catalog', dialogue: 'dialog',
88
+ grey: 'gray', licence: 'license', defence: 'defense',
89
+ practise: 'practice', programme: 'program',
90
+ travelling: 'traveling', travelled: 'traveled', traveller: 'traveler',
91
+ cancelled: 'canceled', cancelling: 'canceling',
92
+ enquire: 'inquire', enquiry: 'inquiry', enquiries: 'inquiries',
93
+ };
94
+ /** Lower-case, fold Arabic orthography, strip punctuation, normalise spelling. */
95
+ function normalizeTranscript(s) {
96
+ let out = String(s == null ? '' : s).toLowerCase();
97
+ for (const [re, to] of CONTRACTIONS)
98
+ out = out.replace(re, to);
99
+ // Hamza carriers, ta marbuta, alef maqsura, tashkeel and tatweel all fold,
100
+ // so "طلب مساعده" matches "طلب مساعدة".
101
+ out = out.replace(/[ً-ْٰـ]/g, '').replace(/[أإآٱ]/g, 'ا').replace(/ى/g, 'ي').replace(/ة/g, 'ه');
102
+ try {
103
+ out = out.replace(/[^\p{L}\p{N}\s]/gu, ' ');
104
+ }
105
+ catch {
106
+ out = out.replace(/[^a-z0-9؀-ۿ\s]/gi, ' ');
107
+ }
108
+ out = out.replace(/\s+/g, ' ').trim();
109
+ if (!out)
110
+ return out;
111
+ return out.split(' ').map((w) => (Object.prototype.hasOwnProperty.call(EN_SPELLING, w) ? EN_SPELLING[w] : w)).join(' ');
112
+ }
113
+ // Tables are keyed by NORMALIZED text. Writing 'أربعة' by hand while input
114
+ // folds to 'اربعه' is exactly the mismatch that silently killed Arabic number
115
+ // parsing in an earlier version.
116
+ const normList = (arr) => arr.map(normalizeTranscript).filter(Boolean);
117
+ const normPairs = (pairs) => pairs.map(([k, v]) => [normalizeTranscript(k), v]).filter(([k]) => !!k);
118
+ const normObj = (obj) => {
119
+ const out = {};
120
+ for (const k of Object.keys(obj)) {
121
+ const n = normalizeTranscript(k);
122
+ if (n)
123
+ out[n] = obj[k];
124
+ }
125
+ return out;
126
+ };
127
+ const YES_WORDS = normList(['yes', 'yeah', 'yep', 'yup', 'yah', 'sure', 'ok', 'okay', 'true', 'correct', 'right',
128
+ 'affirmative', 'absolutely', 'definitely', 'certainly', 'indeed', 'of course', 'i do', 'i would',
129
+ 'agreed', 'agree', 'positive', 'for sure', 'why not',
130
+ 'نعم', 'أجل', 'اجل', 'صحيح', 'أيوه', 'ايوه', 'اكيد', 'أكيد', 'طبعا', 'بالتأكيد', 'موافق',
131
+ 'sí', 'si', 'oui', 'ja', 'sì']);
132
+ const NO_WORDS = normList(['no', 'nope', 'nah', 'naw', 'false', 'incorrect', 'negative', 'never',
133
+ 'not really', 'no way', 'i do not', 'i would not', 'disagree', 'wrong', 'nay',
134
+ 'لا', 'كلا', 'مش', 'ابدا', 'أبدا', 'غير صحيح', 'لأ', 'non', 'nein']);
135
+ const phrasesOf = (list) => list.filter((w) => w.indexOf(' ') !== -1);
136
+ const YES_PHRASES = phrasesOf(YES_WORDS);
137
+ const NO_PHRASES = phrasesOf(NO_WORDS);
138
+ // Strong ordinals outrank weak cardinals: in "the third one", "one" is a
139
+ // pronoun, not a position, and scoring both equally resolved to option 1.
140
+ const ORDINALS_STRONG = normPairs([
141
+ ['first', 0], ['second', 1], ['third', 2], ['fourth', 3], ['fifth', 4], ['sixth', 5],
142
+ ['اول', 0], ['الاول', 0], ['الاولي', 0],
143
+ ['ثاني', 1], ['الثاني', 1], ['الثانيه', 1],
144
+ ['ثالث', 2], ['الثالث', 2], ['الثالثه', 2],
145
+ ['رابع', 3], ['الرابع', 3], ['الرابعه', 3],
146
+ ['خامس', 4], ['الخامس', 4], ['الخامسه', 4],
147
+ ['سادس', 5], ['السادس', 5], ['السادسه', 5],
148
+ ]);
149
+ const ORDINALS_WEAK = normPairs([
150
+ ['one', 0], ['1', 0], ['two', 1], ['2', 1], ['three', 2], ['3', 2],
151
+ ['four', 3], ['4', 3], ['five', 4], ['5', 4], ['six', 5], ['6', 5],
152
+ ['واحد', 0], ['اثنين', 1], ['اتنين', 1], ['ثلاثه', 2], ['تلاته', 2], ['اربعه', 3], ['خمسه', 4], ['سته', 5],
153
+ ]);
154
+ // Letter shortcuts — Latin by position, Arabic in abjad order.
155
+ const LETTER_INDEX = normObj({ a: 0, b: 1, c: 2, d: 3, e: 4, f: 5, 'ا': 0, 'ب': 1, 'ج': 2, 'د': 3, 'ه': 4, 'و': 5 });
156
+ // Discourse markers stripped before letter/ordinal detection, so "um, I would
157
+ // say C please" reduces to "c". Articles are NOT included — they appear inside
158
+ // real option labels.
159
+ const FILLERS = normList(['um', 'uhm', 'uh', 'er', 'erm', 'hmm', 'mm', 'ah', 'oh', 'like', 'well', 'so',
160
+ 'actually', 'basically', 'literally', 'maybe', 'probably', 'definitely', 'honestly', 'personally',
161
+ 'please', 'thanks', 'thank you', 'ok', 'okay', 'right',
162
+ 'يعني', 'اه', 'ايه', 'طيب', 'اوك', 'من فضلك', 'لو سمحت']);
163
+ const FILLER_PHRASES = [/\bi would say\b/g, /\bi think\b/g, /\bi guess\b/g, /\bi mean\b/g,
164
+ /\blet me say\b/g, /\blets say\b/g, /\byou know\b/g, /\bkind of\b/g, /\bsort of\b/g,
165
+ /\bmy answer is\b/g, /\bthe answer is\b/g, /\bi choose\b/g, /\bi pick\b/g, /\bi select\b/g,
166
+ /\bgo with\b/g, /\bاختار\b/g, /\bاعتقد\b/g, /\bجوابي\b/g];
167
+ function stripFillers(said) {
168
+ let out = said;
169
+ for (const re of FILLER_PHRASES)
170
+ out = out.replace(re, ' ');
171
+ const kept = out.split(/\s+/).filter((t) => t && FILLERS.indexOf(t) === -1);
172
+ return kept.join(' ').trim() || said;
173
+ }
174
+ // Connectors between multiple picks — "the staff and the prices" is two
175
+ // answers, and matching the joined phrase as one string finds neither.
176
+ const CONNECTOR_RE = /\s+(?:and|also|plus|as well as|و|وكذلك|وايضا|ايضا)\s+|\s*[,،]\s*/g;
177
+ function editDistance(a, b) {
178
+ if (a === b)
179
+ return 0;
180
+ if (!a.length)
181
+ return b.length;
182
+ if (!b.length)
183
+ return a.length;
184
+ let prev = [];
185
+ for (let j = 0; j <= b.length; j++)
186
+ prev[j] = j;
187
+ for (let i = 1; i <= a.length; i++) {
188
+ const cur = [i];
189
+ for (let j = 1; j <= b.length; j++) {
190
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1));
191
+ }
192
+ prev = cur;
193
+ }
194
+ return prev[b.length];
195
+ }
196
+ function similarity(a, b) {
197
+ const longest = Math.max(a.length, b.length);
198
+ return longest === 0 ? 1 : 1 - editDistance(a, b) / longest;
199
+ }
200
+ /**
201
+ * Strip the prefix every option shares, so "Option A / Option B / Option C"
202
+ * reduces to "a" / "b" / "c" and a respondent who just says "C" is understood.
203
+ */
204
+ function distinctiveLabels(labels) {
205
+ if (labels.length < 2)
206
+ return labels.slice();
207
+ const words = labels.map((l) => l.split(' '));
208
+ const shortest = words.reduce((m, w) => Math.min(m, w.length), Infinity);
209
+ let shared = 0;
210
+ while (shared < shortest - 1) {
211
+ let same = true;
212
+ for (let i = 1; i < words.length; i++)
213
+ if (words[i][shared] !== words[0][shared]) {
214
+ same = false;
215
+ break;
216
+ }
217
+ if (!same)
218
+ break;
219
+ shared++;
220
+ }
221
+ if (shared === 0)
222
+ return labels.slice();
223
+ const stripped = [];
224
+ for (const w of words) {
225
+ const t = w.slice(shared).join(' ');
226
+ if (!t)
227
+ return labels.slice(); // stripping would empty one — keep them all
228
+ stripped.push(t);
229
+ }
230
+ return stripped;
231
+ }
232
+ const lookupPair = (pairs, token) => {
233
+ for (const [k, v] of pairs)
234
+ if (k === token)
235
+ return v;
236
+ return -1;
237
+ };
238
+ /**
239
+ * How strongly does this utterance point at this option? Higher wins.
240
+ * The bands matter more than the numbers: an exact label always beats a
241
+ * distinctive-suffix hit, which always beats a letter or ordinal, which always
242
+ * beats a fuzzy near-miss.
243
+ */
244
+ function scoreOption(said, label, distinctive, index) {
245
+ if (!said)
246
+ return 0;
247
+ if (said === label)
248
+ return 1000 + label.length;
249
+ if (said === distinctive)
250
+ return 900 + distinctive.length;
251
+ const tokens = stripFillers(said).split(' ');
252
+ if (label && said.indexOf(label) !== -1)
253
+ return 700 + label.length;
254
+ if (distinctive) {
255
+ const hit = distinctive.length >= 3 ? (said.indexOf(distinctive) !== -1) : (tokens.indexOf(distinctive) !== -1);
256
+ if (hit)
257
+ return 600 + distinctive.length;
258
+ }
259
+ if (said.length >= 3 && label.indexOf(said) !== -1)
260
+ return 500 + said.length;
261
+ for (const t of tokens) {
262
+ if (LETTER_INDEX[t] === index)
263
+ return 470;
264
+ if (lookupPair(ORDINALS_STRONG, t) === index)
265
+ return 460;
266
+ }
267
+ for (const t of tokens)
268
+ if (lookupPair(ORDINALS_WEAK, t) === index)
269
+ return 430;
270
+ const target = distinctive || label;
271
+ if (target.length >= 3) {
272
+ // Short labels need a tighter threshold: at 5 characters a single edit is
273
+ // already 20% of the word, and "blue"/"blur" are different answers.
274
+ const threshold = target.length <= 5 ? 0.8 : 0.72;
275
+ const sim = Math.max(similarity(said, target), similarity(said, label));
276
+ if (sim >= threshold)
277
+ return 100 + Math.round(sim * 100);
278
+ }
279
+ return 0;
280
+ }
281
+ function matchOptionIndex(said, labels, distinctives) {
282
+ let bestIdx = -1, bestScore = 0;
283
+ for (let i = 0; i < labels.length; i++) {
284
+ const sc = scoreOption(said, labels[i], distinctives[i], i);
285
+ if (sc > bestScore) {
286
+ bestScore = sc;
287
+ bestIdx = i;
288
+ }
289
+ }
290
+ return bestIdx;
291
+ }
292
+ const NUMBER_WORDS = normObj({
293
+ zero: 0, nought: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
294
+ first: 1, second: 2, third: 3, fourth: 4, fifth: 5, sixth: 6, seventh: 7, eighth: 8, ninth: 9, tenth: 10,
295
+ 'صفر': 0, 'واحد': 1, 'واحدة': 1, 'اثنان': 2, 'اثنين': 2, 'اتنين': 2, 'ثلاثة': 3, 'ثلاث': 3, 'تلاتة': 3, 'أربعة': 4, 'اربعة': 4,
296
+ 'خمسة': 5, 'خمس': 5, 'ستة': 6, 'سبعة': 7, 'سبع': 7, 'ثمانية': 8, 'ثماني': 8, 'تمانية': 8, 'تسعة': 9, 'عشرة': 10, 'عشر': 10,
297
+ });
298
+ // What speech recognition emits INSTEAD of a number. These are ordinary words,
299
+ // so they only apply when the utterance is essentially just the answer.
300
+ const NUMBER_HOMOPHONES = normObj({
301
+ oh: 0, won: 1, wun: 1, to: 2, too: 2, tu: 2, tree: 3, free: 3,
302
+ for: 4, fore: 4, forth: 4, fife: 5, sex: 6, sicks: 6, ate: 8, nein: 9,
303
+ });
304
+ function optionLabelOf(o) {
305
+ return String((o && (o.label != null ? o.label : (o.text != null ? o.text : (o.value != null ? o.value : o.alt)))) || '');
306
+ }
307
+ function optionValueOf(o) {
308
+ return (o && o.value != null) ? o.value : optionLabelOf(o);
309
+ }
310
+ function voiceOptionsOf(q) {
311
+ if (Array.isArray(q.options))
312
+ return q.options;
313
+ if (Array.isArray(q.choices))
314
+ return q.choices;
315
+ return [];
316
+ }
317
+ function optionLabelsOf(q) {
318
+ return voiceOptionsOf(q).map(optionLabelOf).filter(Boolean);
319
+ }
320
+ /* ── Matchers — every one returns an index or a primitive, never an answer ── */
321
+ /** Yes/no, including the question's own yes/no labels. `null` = neither. */
322
+ function matchBoolean(q, transcript) {
323
+ const norm = normalizeTranscript(transcript);
324
+ // Multi-word entries can never match token-by-token. Negatives are scanned
325
+ // first: "not really" must not be caught by the positive pass.
326
+ for (const p of NO_PHRASES)
327
+ if (norm.indexOf(p) !== -1)
328
+ return false;
329
+ for (const p of YES_PHRASES)
330
+ if (norm.indexOf(p) !== -1)
331
+ return true;
332
+ const yes = typeof q.yesLabel === 'string' ? normalizeTranscript(q.yesLabel) : '';
333
+ const no = typeof q.noLabel === 'string' ? normalizeTranscript(q.noLabel) : '';
334
+ for (const t of norm.split(' ').filter(Boolean)) {
335
+ if (YES_WORDS.indexOf(t) !== -1)
336
+ return true;
337
+ if (NO_WORDS.indexOf(t) !== -1)
338
+ return false;
339
+ if (yes && t === yes)
340
+ return true;
341
+ if (no && t === no)
342
+ return false;
343
+ }
344
+ if (yes && norm.indexOf(yes) !== -1)
345
+ return true;
346
+ if (no && norm.indexOf(no) !== -1)
347
+ return false;
348
+ return null;
349
+ }
350
+ /** Single choice. Returns the option index, or -1 when nothing matched. */
351
+ function matchOption(q, transcript) {
352
+ const options = voiceOptionsOf(q);
353
+ if (!options.length)
354
+ return -1;
355
+ const said = normalizeTranscript(transcript);
356
+ const labels = options.map((o) => normalizeTranscript(optionLabelOf(o)));
357
+ const dist = distinctiveLabels(labels);
358
+ let idx = matchOptionIndex(said, labels, dist);
359
+ if (idx < 0) {
360
+ // Retry per clause — "um, C I think" still scores, and a stray leading
361
+ // filler doesn't sink the match.
362
+ for (const clause of said.split(CONNECTOR_RE)) {
363
+ const c = (clause || '').trim();
364
+ if (!c)
365
+ continue;
366
+ idx = matchOptionIndex(c, labels, dist);
367
+ if (idx >= 0)
368
+ break;
369
+ }
370
+ }
371
+ return idx;
372
+ }
373
+ /** Multi-choice. Returns ascending option indices; empty when nothing matched. */
374
+ function matchOptions(q, transcript) {
375
+ const options = voiceOptionsOf(q);
376
+ if (!options.length)
377
+ return [];
378
+ const said = normalizeTranscript(transcript);
379
+ const labels = options.map((o) => normalizeTranscript(optionLabelOf(o)));
380
+ const dist = distinctiveLabels(labels);
381
+ const picked = [];
382
+ const add = (n) => { if (n >= 0 && picked.indexOf(n) === -1)
383
+ picked.push(n); };
384
+ // Clause-driven: "the staff and the prices" is two answers, and scoring the
385
+ // joined phrase as one string finds neither.
386
+ for (const clause of said.split(CONNECTOR_RE)) {
387
+ const c = (clause || '').trim();
388
+ if (c)
389
+ add(matchOptionIndex(c, labels, dist));
390
+ }
391
+ // Then sweep for labels mentioned outright, catching picks the connector
392
+ // split missed. Literal containment ONLY here — a fuzzy near-miss must not
393
+ // be able to over-select, because an unwanted extra pick is worse than a
394
+ // missed one when the respondent can't see what was recorded.
395
+ for (let i = 0; i < labels.length; i++) {
396
+ const target = dist[i] || labels[i];
397
+ if (target.length >= 3 && said.indexOf(target) !== -1)
398
+ add(i);
399
+ else if (labels[i].length >= 3 && said.indexOf(labels[i]) !== -1)
400
+ add(i);
401
+ }
402
+ return picked.sort((a, b) => a - b);
403
+ }
404
+ /**
405
+ * Ranking. Returns option indices in the order they were spoken, or an empty
406
+ * array when fewer than two were heard — one name is not a ranking.
407
+ */
408
+ function rankOptions(q, transcript) {
409
+ const options = voiceOptionsOf(q);
410
+ if (!options.length)
411
+ return [];
412
+ const norm = normalizeTranscript(transcript);
413
+ const found = [];
414
+ for (let i = 0; i < options.length; i++) {
415
+ const nl = normalizeTranscript(optionLabelOf(options[i]));
416
+ if (!nl)
417
+ continue;
418
+ const pos = norm.indexOf(nl);
419
+ if (pos >= 0)
420
+ found.push({ i, pos });
421
+ }
422
+ if (found.length < 2)
423
+ return [];
424
+ found.sort((a, b) => a.pos - b.pos);
425
+ return found.map((f) => f.i);
426
+ }
427
+ /** A number inside [min, max], from digits, number words, or a homophone. */
428
+ function matchNumberInRange(transcript, min, max) {
429
+ // Arabic-Indic and Extended Arabic-Indic digits fold to ASCII first —
430
+ // common when speech recognition transcribes spoken numbers in the native
431
+ // script.
432
+ const ascii = String(transcript)
433
+ .replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)))
434
+ .replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)));
435
+ const norm = normalizeTranscript(ascii);
436
+ // Every digit run in order, so "5 out of 10" on a 0-10 scale lands on 5
437
+ // rather than falling through to the word table.
438
+ const digits = norm.match(/\d+(?:\.\d+)?/g);
439
+ if (digits)
440
+ for (const m of digits) {
441
+ const n = Math.round(Number(m));
442
+ if (n >= min && n <= max)
443
+ return n;
444
+ }
445
+ // Word forms matched as whole tokens, never substrings — otherwise
446
+ // "fourteen" matches "four".
447
+ for (const t of norm.split(/\s+/).filter(Boolean)) {
448
+ const n = NUMBER_WORDS[t];
449
+ if (n !== undefined && n >= min && n <= max)
450
+ return n;
451
+ }
452
+ // Homophone rescue, short utterances only: a bare "for" on a 0-10 scale was
453
+ // almost certainly "four"; "for" inside a sentence is a preposition.
454
+ const core = stripFillers(norm).split(/\s+/).filter(Boolean);
455
+ if (core.length <= 2) {
456
+ for (const t of core) {
457
+ const n = NUMBER_HOMOPHONES[t];
458
+ if (n !== undefined && n >= min && n <= max)
459
+ return n;
460
+ }
461
+ }
462
+ return null;
463
+ }
464
+ /** A spoken date as ISO `YYYY-MM-DD`, or null. */
465
+ function matchDate(transcript) {
466
+ const raw = String(transcript).trim();
467
+ // Strip filler words and ordinal suffixes ("January 5th" → "January 5") that
468
+ // trip the Date constructor; speech engines produce them constantly.
469
+ const cleaned = raw.replace(/\b(the|of|on|at)\b/gi, ' ').replace(/(\d+)(st|nd|rd|th)\b/gi, '$1').replace(/\s+/g, ' ').trim();
470
+ for (const c of [raw, cleaned, raw.replace(/(\d+)(st|nd|rd|th)\b/gi, '$1')]) {
471
+ if (!c)
472
+ continue;
473
+ const d = new Date(c);
474
+ if (!isNaN(d.getTime())) {
475
+ const m = String(d.getMonth() + 1).padStart(2, '0');
476
+ const day = String(d.getDate()).padStart(2, '0');
477
+ return `${d.getFullYear()}-${m}-${day}`;
478
+ }
479
+ }
480
+ return null;
481
+ }
482
+ function voiceSupport(type) {
483
+ switch (String(type || '').toLowerCase()) {
484
+ case 'text':
485
+ case 'textbox':
486
+ case 'shorttext':
487
+ case 'comment':
488
+ case 'textarea':
489
+ case 'longtext':
490
+ case 'binary':
491
+ case 'boolean':
492
+ case 'yesno':
493
+ case 'yes_no':
494
+ case 'radio':
495
+ case 'radiogroup':
496
+ case 'dropdown':
497
+ case 'select':
498
+ case 'rating':
499
+ case 'nps':
500
+ case 'checkbox':
501
+ case 'imagepicker':
502
+ case 'image_picker':
503
+ case 'ranking':
504
+ case 'date':
505
+ return 'full';
506
+ case 'matrix':
507
+ return 'matrix';
508
+ case 'content':
509
+ case 'html':
510
+ case 'image':
511
+ case 'expression':
512
+ case 'pagebreak':
513
+ case 'page_break':
514
+ return 'read';
515
+ case 'multitext':
516
+ case 'multi_text':
517
+ case 'multipletext':
518
+ case 'matrixdropdown':
519
+ case 'matrix_dropdown':
520
+ case 'matrixdynamic':
521
+ case 'matrix_dynamic':
522
+ return 'assist';
523
+ case 'paneldynamic':
524
+ case 'panel_dynamic':
525
+ case 'signature':
526
+ case 'fileupload':
527
+ case 'file_upload':
528
+ case 'file':
529
+ return 'none';
530
+ default:
531
+ return 'none';
532
+ }
533
+ }
534
+ /** Can a voice-only session get through this question unaided? */
535
+ function voiceCanHandle(type) {
536
+ const s = voiceSupport(type);
537
+ return s === 'full' || s === 'matrix' || s === 'read';
538
+ }
539
+ /**
540
+ * blocking = required questions voice can't handle → the session must not start.
541
+ * skippable = optional ones → announced and skipped.
542
+ */
543
+ function auditVoiceQuestions(questions) {
544
+ const blocking = [], skippable = [];
545
+ for (const q of (Array.isArray(questions) ? questions : [])) {
546
+ if (!q)
547
+ continue;
548
+ const t = String(q.type || '').toLowerCase();
549
+ if (t === 'pagebreak' || t === 'page_break')
550
+ continue;
551
+ if (voiceCanHandle(String(q.type || '')))
552
+ continue;
553
+ if (q.isRequired || q.required)
554
+ blocking.push(q);
555
+ else
556
+ skippable.push(q);
557
+ }
558
+ return { blocking, skippable };
559
+ }
560
+ const SCAFFOLDS = {
561
+ en: {
562
+ scale: (min, max, minL, maxL) => {
563
+ const r = `Answer with a number from ${min} to ${max}`;
564
+ return (minL && maxL) ? `${r}. ${min} means ${minL}, ${max} means ${maxL}.` : `${r}.`;
565
+ },
566
+ binary: (y, n) => `Please answer ${y || 'yes'} or ${n || 'no'}.`,
567
+ options: (o) => `Options are: ${o.join(', ')}.`,
568
+ multi: (o) => `Multiple choices allowed. Options are: ${o.join(', ')}. Say the ones that apply.`,
569
+ ranking: (o) => `Rank in order of preference: ${o.join(', ')}.`,
570
+ date: () => 'Please say the date.',
571
+ matrixStem: (stem) => `${stem}. I’ll ask you row by row.`,
572
+ matrixRow: (row, cols) => `${row}. Options: ${cols.join(', ')}.`,
573
+ },
574
+ ar: {
575
+ scale: (min, max, minL, maxL) => {
576
+ const r = `أجب برقم من ${min} إلى ${max}`;
577
+ return (minL && maxL) ? `${r}. ${min} يعني ${minL}، ${max} يعني ${maxL}.` : `${r}.`;
578
+ },
579
+ binary: (y, n) => `يرجى الإجابة ${y || 'نعم'} أو ${n || 'لا'}.`,
580
+ options: (o) => `الخيارات: ${o.join('، ')}.`,
581
+ multi: (o) => `يمكن اختيار أكثر من إجابة. الخيارات: ${o.join('، ')}. قل الخيارات التي تنطبق.`,
582
+ ranking: (o) => `رتب حسب الأفضلية: ${o.join('، ')}.`,
583
+ date: () => 'يرجى قول التاريخ.',
584
+ matrixStem: (stem) => `${stem}. سأسألك عن كل صف على حدة.`,
585
+ matrixRow: (row, cols) => `${row}. الخيارات: ${cols.join('، ')}.`,
586
+ },
587
+ };
588
+ function scaffoldFor(lang) {
589
+ return SCAFFOLDS[shortCode(lang)] || SCAFFOLDS.en;
590
+ }
591
+ function voiceDisclaimer(n, lang) {
592
+ if (shortCode(lang) === 'ar')
593
+ return 'ملاحظة: ' + n + (n > 1 ? ' أسئلة' : ' سؤال') + ' لا يمكن الإجابة عليها صوتياً وسيتم تخطيها.';
594
+ return 'Heads up — ' + n + ' question' + (n > 1 ? 's' : '') + " can't be answered by voice and will be skipped.";
595
+ }
596
+ function skipNoteText(lang) {
597
+ if (shortCode(lang) === 'ar')
598
+ return 'هذا السؤال يحتاج إدخالاً يدوياً — سيتم تخطيه.';
599
+ return 'This one needs manual input — skipping it.';
600
+ }
601
+ /** What the session says out loud to ask this question. */
602
+ function buildPromptText(q, lang) {
603
+ const s = scaffoldFor(lang);
604
+ const base = String(q.title || q.text || q.label || '').replace(/<[^>]*>/g, '').trim();
605
+ const type = String(q.type || '').toLowerCase();
606
+ if (['binary', 'boolean', 'yesno', 'yes_no'].indexOf(type) !== -1) {
607
+ return `${base}. ${s.binary(typeof q.yesLabel === 'string' ? q.yesLabel : '', typeof q.noLabel === 'string' ? q.noLabel : '')}`;
608
+ }
609
+ if (type === 'rating') {
610
+ return `${base}. ${s.scale(typeof q.minValue === 'number' ? q.minValue : 1, typeof q.maxValue === 'number' ? q.maxValue : 5, typeof q.minLabel === 'string' ? q.minLabel : undefined, typeof q.maxLabel === 'string' ? q.maxLabel : undefined)}`;
611
+ }
612
+ if (type === 'nps') {
613
+ // Always 0-10 regardless of the question's own min/max — every NPS
614
+ // renderer hardcodes that scale.
615
+ return `${base}. ${s.scale(0, 10, typeof q.minLabel === 'string' ? q.minLabel : 'Not at all likely', typeof q.maxLabel === 'string' ? q.maxLabel : 'Extremely likely')}`;
616
+ }
617
+ if (['radio', 'radiogroup', 'dropdown', 'select', 'imagepicker', 'image_picker'].indexOf(type) !== -1) {
618
+ const o = optionLabelsOf(q);
619
+ return o.length ? `${base}. ${s.options(o)}` : base;
620
+ }
621
+ if (type === 'checkbox') {
622
+ const o = optionLabelsOf(q);
623
+ return o.length ? `${base}. ${s.multi(o)}` : base;
624
+ }
625
+ if (type === 'ranking') {
626
+ const o = optionLabelsOf(q);
627
+ return o.length ? `${base}. ${s.ranking(o)}` : base;
628
+ }
629
+ if (type === 'date')
630
+ return `${base}. ${s.date()}`;
631
+ return base;
632
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cxpinsight/survey-spec",
3
- "version": "0.1.1",
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.",
3
+ "version": "0.3.0",
4
+ "description": "The survey specification the renderers share \u2014 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": [