@lokascript/domain-voice 2.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.
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Voice/Accessibility Code Generator
3
+ *
4
+ * Transforms semantic AST nodes into executable JavaScript snippets
5
+ * for DOM manipulation, navigation, and accessibility actions.
6
+ */
7
+
8
+ import type { CodeGenerator, SemanticNode } from '@lokascript/framework';
9
+ import { extractRoleValue } from '@lokascript/framework';
10
+
11
+ // =============================================================================
12
+ // String Escaping
13
+ // =============================================================================
14
+
15
+ function esc(s: string): string {
16
+ return s
17
+ .replace(/\\/g, '\\\\')
18
+ .replace(/'/g, "\\'")
19
+ .replace(/"/g, '\\"')
20
+ .replace(/`/g, '\\`')
21
+ .replace(/\n/g, '\\n')
22
+ .replace(/\r/g, '\\r')
23
+ .replace(/\$/g, '\\$');
24
+ }
25
+
26
+ // =============================================================================
27
+ // Shared Element-Finding Helper (injected into generated code)
28
+ // Self-initializing guard: safe to include multiple times, defines only once.
29
+ // =============================================================================
30
+
31
+ const FIND_EL = [
32
+ `if(!window._findEl){window._findEl=function(q,root){`,
33
+ ` root=root||document;`,
34
+ ` if(q.startsWith('#')||q.startsWith('.')||q.includes('[')){`,
35
+ ` try{return root.querySelector(q)}catch(e){}`,
36
+ ` }`,
37
+ ` var el=root.querySelector('[aria-label="'+q+'"]');`,
38
+ ` if(el)return el;`,
39
+ ` el=root.querySelector('[role="'+q+'"]');`,
40
+ ` if(el)return el;`,
41
+ ` var c=root.querySelectorAll('button,a,input,[role="button"],[tabindex]');`,
42
+ ` var lq=q.toLowerCase();`,
43
+ ` for(var i=0;i<c.length;i++){`,
44
+ ` if((c[i].textContent||'').toLowerCase().includes(lq))return c[i];`,
45
+ ` if((c[i].getAttribute('aria-label')||'').toLowerCase().includes(lq))return c[i];`,
46
+ ` }`,
47
+ ` return null;`,
48
+ `}}`,
49
+ ].join('');
50
+
51
+ // =============================================================================
52
+ // i18n Word Sets (all 8 languages)
53
+ // =============================================================================
54
+
55
+ const SELECT_ALL_WORDS = new Set(['all', 'todo', '全て', '全部', 'الكل', '전체', 'hepsi', 'tout']);
56
+ const TAB_WORDS = new Set(['tab', 'pestaña', 'タブ', '탭', '标签', 'sekme', 'onglet']);
57
+ const DIALOG_WORDS = new Set([
58
+ 'dialog',
59
+ 'modal',
60
+ 'diálogo',
61
+ 'ダイアログ',
62
+ '대화상자',
63
+ '对话框',
64
+ 'diyalog',
65
+ 'dialogue',
66
+ ]);
67
+ const PAGE_WORDS = new Set(['page', 'página', 'ページ', 'الصفحة', '페이지', '页面', 'sayfa']);
68
+
69
+ // =============================================================================
70
+ // Per-Command Generators
71
+ // =============================================================================
72
+
73
+ function generateNavigate(node: SemanticNode): string {
74
+ const dest = extractRoleValue(node, 'destination');
75
+ if (!dest) return '// navigate: missing destination';
76
+ if (dest.startsWith('/') || dest.startsWith('http')) {
77
+ return `window.location.href = '${esc(dest)}';`;
78
+ }
79
+ return [
80
+ FIND_EL,
81
+ `var el = _findEl('${esc(dest)}');`,
82
+ `if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });`,
83
+ `else window.location.hash = '${esc(dest)}';`,
84
+ ].join('\n');
85
+ }
86
+
87
+ function generateClick(node: SemanticNode): string {
88
+ const patient = extractRoleValue(node, 'patient');
89
+ if (!patient) return '// click: missing target';
90
+ return [FIND_EL, `var el = _findEl('${esc(patient)}');`, `if (el) el.click();`].join('\n');
91
+ }
92
+
93
+ function generateType(node: SemanticNode): string {
94
+ const text = extractRoleValue(node, 'patient');
95
+ if (!text) return '// type: missing text';
96
+ const dest = extractRoleValue(node, 'destination');
97
+ const target = dest
98
+ ? `_findEl('${esc(dest)}') || document.activeElement`
99
+ : `document.activeElement`;
100
+ return [
101
+ FIND_EL,
102
+ `var el = ${target};`,
103
+ `if (el && ('value' in el || el.isContentEditable)) {`,
104
+ ` if ('value' in el) { el.value = (el.value || '') + '${esc(text)}'; }`,
105
+ ` else { el.textContent = (el.textContent || '') + '${esc(text)}'; }`,
106
+ ` el.dispatchEvent(new Event('input', { bubbles: true }));`,
107
+ `}`,
108
+ ].join('\n');
109
+ }
110
+
111
+ function generateScroll(node: SemanticNode): string {
112
+ const manner = (extractRoleValue(node, 'manner') || 'down').toLowerCase();
113
+ const quantity = extractRoleValue(node, 'quantity');
114
+ const px = quantity ? parseInt(quantity, 10) || 300 : 300;
115
+
116
+ switch (manner) {
117
+ case 'up':
118
+ return `window.scrollBy({ top: -${px}, behavior: 'smooth' });`;
119
+ case 'down':
120
+ return `window.scrollBy({ top: ${px}, behavior: 'smooth' });`;
121
+ case 'left':
122
+ return `window.scrollBy({ left: -${px}, behavior: 'smooth' });`;
123
+ case 'right':
124
+ return `window.scrollBy({ left: ${px}, behavior: 'smooth' });`;
125
+ case 'top':
126
+ return `window.scrollTo({ top: 0, behavior: 'smooth' });`;
127
+ case 'bottom':
128
+ return `window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });`;
129
+ default:
130
+ return `window.scrollBy({ top: ${px}, behavior: 'smooth' });`;
131
+ }
132
+ }
133
+
134
+ function generateRead(node: SemanticNode): string {
135
+ const patient = extractRoleValue(node, 'patient');
136
+ if (!patient) return '// read: missing target';
137
+ return [
138
+ FIND_EL,
139
+ `var el = _findEl('${esc(patient)}');`,
140
+ `if (el && el.textContent) {`,
141
+ ` var utterance = new SpeechSynthesisUtterance(el.textContent);`,
142
+ ` speechSynthesis.speak(utterance);`,
143
+ `}`,
144
+ ].join('\n');
145
+ }
146
+
147
+ function generateZoom(node: SemanticNode): string {
148
+ const manner = (extractRoleValue(node, 'manner') || 'in').toLowerCase();
149
+ if (manner === 'reset') {
150
+ return [
151
+ `document.documentElement.dataset.zoom = '1';`,
152
+ `document.documentElement.style.transform = '';`,
153
+ ].join('\n');
154
+ }
155
+ const factor = manner === 'out' ? 0.9 : 1.1;
156
+ return [
157
+ `var s = parseFloat(document.documentElement.dataset.zoom || '1');`,
158
+ `s = Math.round(s * ${factor} * 100) / 100;`,
159
+ `document.documentElement.dataset.zoom = s;`,
160
+ `document.documentElement.style.transform = 'scale(' + s + ')';`,
161
+ `document.documentElement.style.transformOrigin = 'top left';`,
162
+ ].join('\n');
163
+ }
164
+
165
+ function generateSelect(node: SemanticNode): string {
166
+ const patient = extractRoleValue(node, 'patient');
167
+ if (!patient) return '// select: missing target';
168
+ const target = SELECT_ALL_WORDS.has(patient) ? 'document.body' : `_findEl('${esc(patient)}')`;
169
+ const lines = SELECT_ALL_WORDS.has(patient) ? [] : [FIND_EL];
170
+ lines.push(
171
+ `var el = ${target};`,
172
+ `if (el) {`,
173
+ ` var range = document.createRange();`,
174
+ ` range.selectNodeContents(el);`,
175
+ ` var sel = window.getSelection();`,
176
+ ` sel.removeAllRanges();`,
177
+ ` sel.addRange(range);`,
178
+ `}`
179
+ );
180
+ return lines.join('\n');
181
+ }
182
+
183
+ function generateBack(node: SemanticNode): string {
184
+ const quantity = extractRoleValue(node, 'quantity');
185
+ const n = quantity ? parseInt(quantity, 10) || 1 : 1;
186
+ return `history.go(-${n});`;
187
+ }
188
+
189
+ function generateForward(node: SemanticNode): string {
190
+ const quantity = extractRoleValue(node, 'quantity');
191
+ const n = quantity ? parseInt(quantity, 10) || 1 : 1;
192
+ return `history.go(${n});`;
193
+ }
194
+
195
+ function generateFocus(node: SemanticNode): string {
196
+ const patient = extractRoleValue(node, 'patient');
197
+ if (!patient) return '// focus: missing target';
198
+ return [FIND_EL, `var el = _findEl('${esc(patient)}');`, `if (el) el.focus();`].join('\n');
199
+ }
200
+
201
+ function generateClose(node: SemanticNode): string {
202
+ const patient = extractRoleValue(node, 'patient') || '';
203
+ if (TAB_WORDS.has(patient)) {
204
+ return `window.close();`;
205
+ }
206
+ if (DIALOG_WORDS.has(patient)) {
207
+ return `var d = document.querySelector('dialog[open]'); if (d) d.close();`;
208
+ }
209
+ // Default: try to close any open dialog or modal
210
+ return [
211
+ `var d = document.querySelector('dialog[open]');`,
212
+ `if (d) { d.close(); }`,
213
+ `else {`,
214
+ ` var m = document.querySelector('[role="dialog"], .modal.show, .modal.open');`,
215
+ ` if (m) m.remove();`,
216
+ `}`,
217
+ ].join('\n');
218
+ }
219
+
220
+ function generateOpen(node: SemanticNode): string {
221
+ const patient = extractRoleValue(node, 'patient');
222
+ if (!patient) return '// open: missing target';
223
+ if (patient.startsWith('/') || patient.startsWith('http')) {
224
+ return `window.open('${esc(patient)}', '_blank');`;
225
+ }
226
+ return [FIND_EL, `var el = _findEl('${esc(patient)}');`, `if (el) el.click();`].join('\n');
227
+ }
228
+
229
+ function generateSearch(node: SemanticNode): string {
230
+ const query = extractRoleValue(node, 'patient');
231
+ if (!query) return '// search: missing query';
232
+ const dest = extractRoleValue(node, 'destination');
233
+ if (dest && PAGE_WORDS.has(dest)) {
234
+ // window.find() is non-standard but no standard alternative exists for "find in page"
235
+ return `window.find('${esc(query)}');`;
236
+ }
237
+ const selector = dest
238
+ ? `'${esc(dest)}'`
239
+ : `'input[type="search"], [role="searchbox"], input[name="q"], input[name="search"]'`;
240
+ return [
241
+ `var searchInput = document.querySelector(${selector});`,
242
+ `if (searchInput) {`,
243
+ ` searchInput.value = '${esc(query)}';`,
244
+ ` searchInput.dispatchEvent(new Event('input', { bubbles: true }));`,
245
+ ` if (searchInput.form) searchInput.form.submit();`,
246
+ `}`,
247
+ ].join('\n');
248
+ }
249
+
250
+ function generateHelp(node: SemanticNode): string {
251
+ const topic = extractRoleValue(node, 'patient');
252
+ const commands = [
253
+ 'navigate',
254
+ 'click',
255
+ 'type',
256
+ 'scroll',
257
+ 'read',
258
+ 'zoom',
259
+ 'select',
260
+ 'back',
261
+ 'forward',
262
+ 'focus',
263
+ 'close',
264
+ 'open',
265
+ 'search',
266
+ 'help',
267
+ ];
268
+ if (topic) {
269
+ return `console.log('Help: ${esc(topic)}');`;
270
+ }
271
+ return `console.log('Available commands: ${commands.join(', ')}');`;
272
+ }
273
+
274
+ // =============================================================================
275
+ // Public Code Generator
276
+ // =============================================================================
277
+
278
+ export const voiceCodeGenerator: CodeGenerator = {
279
+ generate(node: SemanticNode): string {
280
+ switch (node.action) {
281
+ case 'navigate':
282
+ return generateNavigate(node);
283
+ case 'click':
284
+ return generateClick(node);
285
+ case 'type':
286
+ return generateType(node);
287
+ case 'scroll':
288
+ return generateScroll(node);
289
+ case 'read':
290
+ return generateRead(node);
291
+ case 'zoom':
292
+ return generateZoom(node);
293
+ case 'select':
294
+ return generateSelect(node);
295
+ case 'back':
296
+ return generateBack(node);
297
+ case 'forward':
298
+ return generateForward(node);
299
+ case 'focus':
300
+ return generateFocus(node);
301
+ case 'close':
302
+ return generateClose(node);
303
+ case 'open':
304
+ return generateOpen(node);
305
+ case 'search':
306
+ return generateSearch(node);
307
+ case 'help':
308
+ return generateHelp(node);
309
+ default:
310
+ return `// Unknown voice command: ${node.action}`;
311
+ }
312
+ },
313
+ };