@c2-markdown-ui/mdui-lang 0.0.0-stage → 0.4.3

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/parser.js ADDED
@@ -0,0 +1,2566 @@
1
+ // Known widget types for early detection
2
+ const WIDGET_TYPES = [
3
+ 'text', 'text-input', 'button-group', 'select', 'select-multi', 'radio-group', 'cascade', 'tree-select', 'slider', 'switch', 'date-picker', 'number-input',
4
+ 'form', 'row', 'chart-line', 'chart-bar', 'chart-pie', 'chart-scatter',
5
+ 'multiple-choice-question', 'short-answer-question', 'quiz'
6
+ ];
7
+ export class DSLParser {
8
+ parse(input) {
9
+ try {
10
+ const tokens = this.tokenize(input.trim());
11
+ if (tokens.length === 0) {
12
+ return { success: false, error: "Empty input" };
13
+ }
14
+ const widgetType = tokens[0];
15
+ switch (widgetType) {
16
+ case 'text':
17
+ return this.parseText(tokens);
18
+ case 'text-input':
19
+ return this.parseTextInput(tokens);
20
+ case 'button-group':
21
+ return this.parseButtonGroup(tokens);
22
+ case 'button':
23
+ return this.parseButton(tokens);
24
+ case 'select':
25
+ return this.parseSelect(tokens);
26
+ case 'select-multi':
27
+ return this.parseSelectMulti(tokens);
28
+ case 'radio-group':
29
+ return this.parseRadioGroup(tokens);
30
+ case 'cascade':
31
+ return this.parseCascade(tokens);
32
+ case 'tree-select':
33
+ return this.parseTreeSelect(tokens);
34
+ case 'slider':
35
+ return this.parseSlider(tokens);
36
+ case 'switch':
37
+ return this.parseSwitch(tokens);
38
+ case 'date-picker':
39
+ return this.parseDatePicker(tokens);
40
+ case 'number-input':
41
+ return this.parseNumberInput(tokens);
42
+ case 'form':
43
+ return this.parseForm(tokens, input);
44
+ case 'row':
45
+ return this.parseRow(tokens, input);
46
+ case 'chart-line':
47
+ case 'chart-bar':
48
+ case 'chart-pie':
49
+ case 'chart-scatter':
50
+ return this.parseChart(tokens, input, widgetType);
51
+ case 'multiple-choice-question':
52
+ return this.parseMultipleChoiceQuestion(tokens);
53
+ case 'short-answer-question':
54
+ return this.parseShortAnswerQuestion(tokens);
55
+ case 'quiz':
56
+ return this.parseQuiz(tokens, input);
57
+ default:
58
+ return { success: false, error: `Unknown widget type: ${widgetType}` };
59
+ }
60
+ }
61
+ catch (error) {
62
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
63
+ }
64
+ }
65
+ tokenize(input) {
66
+ const tokens = [];
67
+ let current = '';
68
+ let inQuotes = false;
69
+ let inArray = false;
70
+ // 数组内容中的引号字符(" 或 '):引号内的 ] / 空格不截断数组(JSON 字符串值可含 ])
71
+ let arrayQuote = '';
72
+ // 数组内容中的嵌套括号深度(JSON 对象数组 [{...} {...}] 等)
73
+ let arrayDepth = 0;
74
+ let i = 0;
75
+ while (i < input.length) {
76
+ const char = input[i];
77
+ if (char === '"' && !inArray) {
78
+ inQuotes = !inQuotes;
79
+ i++;
80
+ continue;
81
+ }
82
+ // data[...]/data{...}:紧随 data: 前缀的对象数组/对象作为完整单 token 处理,
83
+ // 引号内空白/嵌套括号不拆散;外层 ] 或 } 闭合才结束
84
+ if (!inQuotes && !inArray && current === '' && input.startsWith('data:', i)) {
85
+ i += 5; // 消费 data:
86
+ while (i < input.length && (input[i] === ' ' || input[i] === '\t'))
87
+ i++;
88
+ if (i < input.length && (input[i] === '[' || input[i] === '{')) {
89
+ const arr = this.scanDataArray(input, i);
90
+ tokens.push('data:' + arr);
91
+ i += arr.length;
92
+ continue;
93
+ }
94
+ // 没有 [,回退到普通 token 处理(data 标量,当前无此场景)
95
+ current = 'data:';
96
+ // 不 continue,下面的普通分支接着处理
97
+ }
98
+ // 仅 token 起始位置的 [ 进入数组模式:数组总是独立 token;
99
+ // pattern:^1[0-9]{10}$ 这类 key:value 值中的 [ 保持字面字符,避免被拆散
100
+ if (char === '[' && !inQuotes && !current) {
101
+ inArray = true;
102
+ arrayQuote = '';
103
+ arrayDepth = 0;
104
+ i++;
105
+ continue;
106
+ }
107
+ if (inArray) {
108
+ // 引号感知:引号内的 ] / 空格 / 括号都是字面字符(AI 常写单引号,一并支持)
109
+ if (!arrayQuote && (char === '"' || char === "'")) {
110
+ arrayQuote = char;
111
+ }
112
+ else if (arrayQuote && char === arrayQuote) {
113
+ arrayQuote = '';
114
+ }
115
+ else if (!arrayQuote) {
116
+ if (char === '[' || char === '{')
117
+ arrayDepth++;
118
+ else if (char === ']' || char === '}') {
119
+ // 嵌套括号未配平时,外层 ] 只是内容字符
120
+ if (arrayDepth > 0)
121
+ arrayDepth--;
122
+ }
123
+ }
124
+ // 外层 ] 闭合才结束数组 token
125
+ if (char === ']' && !arrayQuote && arrayDepth === 0) {
126
+ inArray = false;
127
+ tokens.push(`[${current.trim()}]`);
128
+ current = '';
129
+ i++;
130
+ continue;
131
+ }
132
+ current += char;
133
+ i++;
134
+ continue;
135
+ }
136
+ if ((char === ' ' || char === '\n') && !inQuotes && !inArray) {
137
+ if (current.trim()) {
138
+ tokens.push(current.trim());
139
+ current = '';
140
+ }
141
+ i++;
142
+ continue;
143
+ }
144
+ current += char;
145
+ i++;
146
+ }
147
+ if (current.trim()) {
148
+ tokens.push(current.trim());
149
+ }
150
+ return tokens;
151
+ }
152
+ /** 从位置 i(指向 [ 或 {)开始,按引号感知 + 深度追踪,返回完整 data:[...]/data:{...} token 串。
153
+ * 起始是 { 时按对象深度配平;输入被截断(流式未写完)时取到末尾,由 parseCascadeNodes 报错兜底 */
154
+ scanDataArray(input, i) {
155
+ const open = input[i];
156
+ const close = open === '{' ? '}' : ']';
157
+ let depth = 0;
158
+ let quote = '';
159
+ let j = i;
160
+ for (; j < input.length; j++) {
161
+ const c = input[j];
162
+ if (quote) {
163
+ if (c === quote)
164
+ quote = '';
165
+ continue;
166
+ }
167
+ if (c === '"' || c === "'") {
168
+ quote = c;
169
+ continue;
170
+ }
171
+ if (c === '[' || c === '{')
172
+ depth++;
173
+ else if (c === ']' || c === '}') {
174
+ depth--;
175
+ if (depth === 0 && c === close) {
176
+ j++;
177
+ break;
178
+ }
179
+ }
180
+ }
181
+ return input.slice(i, j);
182
+ }
183
+ /**
184
+ * Normalize array syntax to handle common AI output variations.
185
+ * Converts formats like ["a","b","c"] or ["a", "b", "c"] to ["a" "b" "c"]
186
+ */
187
+ normalizeArraySyntax(arrayToken) {
188
+ if (!arrayToken.startsWith('[') || !arrayToken.endsWith(']')) {
189
+ return arrayToken;
190
+ }
191
+ const content = arrayToken.slice(1, -1).trim();
192
+ if (!content)
193
+ return '[]';
194
+ // Strategy: Parse the content handling both comma and space separators,
195
+ // with or without quotes, then reconstruct in canonical format
196
+ const items = [];
197
+ let current = '';
198
+ let inQuotes = false;
199
+ let quoteChar = '';
200
+ let i = 0;
201
+ while (i < content.length) {
202
+ const char = content[i];
203
+ // Handle both single and double quotes
204
+ if ((char === '"' || char === "'") && !inQuotes) {
205
+ inQuotes = true;
206
+ quoteChar = char;
207
+ i++;
208
+ continue;
209
+ }
210
+ if (char === quoteChar && inQuotes) {
211
+ inQuotes = false;
212
+ quoteChar = '';
213
+ i++;
214
+ continue;
215
+ }
216
+ // Comma or space outside quotes = separator
217
+ if ((char === ',' || char === ' ') && !inQuotes) {
218
+ if (current.trim()) {
219
+ items.push(current.trim());
220
+ current = '';
221
+ }
222
+ i++;
223
+ continue;
224
+ }
225
+ current += char;
226
+ i++;
227
+ }
228
+ if (current.trim()) {
229
+ items.push(current.trim());
230
+ }
231
+ // Reconstruct in canonical format: [item1 item2 item3]
232
+ // Quote items that contain spaces
233
+ const normalized = items.map(item => {
234
+ if (item.includes(' ')) {
235
+ return `"${item}"`;
236
+ }
237
+ return item;
238
+ }).join(' ');
239
+ return `[${normalized}]`;
240
+ }
241
+ parseArray(arrayToken) {
242
+ if (!arrayToken.startsWith('[') || !arrayToken.endsWith(']')) {
243
+ throw new Error('Invalid array format');
244
+ }
245
+ // First normalize the array syntax to handle AI variations
246
+ const normalizedToken = this.normalizeArraySyntax(arrayToken);
247
+ const content = normalizedToken.slice(1, -1).trim();
248
+ if (!content)
249
+ return [];
250
+ // Parse array content with support for quoted strings
251
+ const items = [];
252
+ let current = '';
253
+ let inQuotes = false;
254
+ let i = 0;
255
+ while (i < content.length) {
256
+ const char = content[i];
257
+ if (char === '"') {
258
+ inQuotes = !inQuotes;
259
+ i++;
260
+ continue;
261
+ }
262
+ if (char === ' ' && !inQuotes) {
263
+ if (current.trim()) {
264
+ items.push(current.trim());
265
+ current = '';
266
+ }
267
+ i++;
268
+ continue;
269
+ }
270
+ current += char;
271
+ i++;
272
+ }
273
+ if (current.trim()) {
274
+ items.push(current.trim());
275
+ }
276
+ return items;
277
+ }
278
+ /** 从字段 token 中提取 label:/hint:/pattern:/message: 修饰符(key:value 形式,与位置参数顺序无关)
279
+ * 及 required 布尔标记,返回剩余位置参数(不含类型名)与各修饰值。
280
+ * 含空格的值写作 key:"含 空格",tokenize 剥引号后仍是单 token */
281
+ extractFieldMods(tokens) {
282
+ const mods = { plain: [] };
283
+ for (let i = 1; i < tokens.length; i++) {
284
+ const t = tokens[i];
285
+ if (t === 'required') {
286
+ mods.required = true;
287
+ }
288
+ else if (t === 'disabled') {
289
+ mods.disabled = true;
290
+ }
291
+ else if (t === 'lazy') {
292
+ // 级联懒加载标记(url 模式下点击父级后逐级请求子级)
293
+ mods.lazy = true;
294
+ }
295
+ else if (t === 'multiple') {
296
+ // tree-select 多选模式(antd multiple,默认单选)
297
+ mods.multiple = true;
298
+ }
299
+ else if (t === 'checkStrictly') {
300
+ // tree-select 父子节点选中不关联(antd checkStrictly)
301
+ mods.checkStrictly = true;
302
+ }
303
+ else if (t === 'treeLine') {
304
+ // tree-select 树连接线(antd treeLine)
305
+ mods.treeLine = true;
306
+ }
307
+ else if (t.startsWith('maxTagCount:')) {
308
+ // tree-select 触发器最多显示的标签数(antd maxTagCount)
309
+ const n = parseInt(t.split(':')[1]);
310
+ if (!isNaN(n) && n >= 0)
311
+ mods.maxTagCount = n;
312
+ }
313
+ else if (t.startsWith('strategy:')) {
314
+ // tree-select 选中值回显策略(antd showCheckedStrategy)
315
+ const s = t.split(':')[1];
316
+ if (s === 'all' || s === 'parent' || s === 'child')
317
+ mods.checkedStrategy = s;
318
+ }
319
+ else if (t === 'searchable') {
320
+ // select / tree-select 可搜索标记(下拉面板内置搜索框)
321
+ mods.searchable = true;
322
+ }
323
+ else if (t === 'button') {
324
+ // radio-group 按钮样式标记(渲染为按钮组而非圆点单选)
325
+ mods.button = true;
326
+ }
327
+ else if (t === 'horizontal') {
328
+ // select-multi 选项横向排列
329
+ mods.direction = 'horizontal';
330
+ }
331
+ else if (t === 'vertical') {
332
+ // select-multi 选项纵向排列(默认,显式写也支持)
333
+ mods.direction = 'vertical';
334
+ }
335
+ else if (t.startsWith('direction:')) {
336
+ const d = t.split(':')[1]?.toLowerCase();
337
+ if (d === 'horizontal' || d === 'vertical')
338
+ mods.direction = d;
339
+ }
340
+ else if (t.startsWith('columns:')) {
341
+ // select-multi 自定义布局:每行列数(≥1)
342
+ const n = parseInt(t.split(':')[1]);
343
+ if (!isNaN(n) && n >= 1)
344
+ mods.columns = n;
345
+ }
346
+ else if (t.startsWith('label:')) {
347
+ mods.label = t.slice('label:'.length);
348
+ }
349
+ else if (t.startsWith('hint:')) {
350
+ mods.hint = t.slice('hint:'.length);
351
+ }
352
+ else if (t.startsWith('pattern:')) {
353
+ mods.pattern = t.slice('pattern:'.length);
354
+ }
355
+ else if (t.startsWith('message:')) {
356
+ mods.message = t.slice('message:'.length);
357
+ }
358
+ else if (t.startsWith('url:')) {
359
+ // url:"" 表示远端模式但路径待填(空串是合法值,不能被当作"未设置")
360
+ mods.url = t.slice('url:'.length);
361
+ }
362
+ else if (t.startsWith('data:[') || t.startsWith('data:{')) {
363
+ // 级联嵌套对象树数据源:data:[{"value":...} ...] 或 data:{...}(保留原样交给 parseCascadeNodes 解析)
364
+ mods.data = t.slice('data:'.length);
365
+ }
366
+ else if (t.startsWith('placeholder:')) {
367
+ mods.placeholder = t.slice('placeholder:'.length);
368
+ }
369
+ else if (t.startsWith('checkedText:')) {
370
+ // switch 选中时文案(antd checkedChildren)
371
+ mods.checkedText = t.slice('checkedText:'.length);
372
+ }
373
+ else if (t.startsWith('unCheckedText:')) {
374
+ // switch 未选中时文案(antd unCheckedChildren)
375
+ mods.unCheckedText = t.slice('unCheckedText:'.length);
376
+ }
377
+ else {
378
+ mods.plain.push(t);
379
+ }
380
+ }
381
+ return mods;
382
+ }
383
+ parseTextInput(tokens) {
384
+ const mods = this.extractFieldMods(tokens);
385
+ const plain = mods.plain;
386
+ if (plain.length < 1) {
387
+ return { success: false, error: "text-input requires at least an id" };
388
+ }
389
+ const widget = {
390
+ type: "text-input",
391
+ id: plain[0]
392
+ };
393
+ if (plain.length > 1)
394
+ widget.label = plain[1];
395
+ if (plain.length > 2)
396
+ widget.placeholder = plain[2];
397
+ if (plain.length > 3)
398
+ widget.default = plain[3];
399
+ if (mods.label)
400
+ widget.label = mods.label;
401
+ if (mods.hint)
402
+ widget.hint = mods.hint;
403
+ if (mods.required)
404
+ widget.required = true;
405
+ if (mods.pattern)
406
+ widget.pattern = mods.pattern;
407
+ if (mods.message)
408
+ widget.message = mods.message;
409
+ if (mods.disabled)
410
+ widget.disabled = true;
411
+ return { success: true, widget };
412
+ }
413
+ /** 解析单个按钮:button id "文字" color:#3b82f6 size:large */
414
+ /** 纯文字组件:text [id] "内容" [size:N] [color:xx] [bold] [align:xx]
415
+ * 非参数 token:1 个=纯内容(id 自动生成);≥2 个=第一个是 id,第二个是内容 */
416
+ parseText(tokens) {
417
+ const widget = { type: "text", text: "" };
418
+ const plainTokens = [];
419
+ for (let i = 1; i < tokens.length; i++) {
420
+ const t = tokens[i];
421
+ if (t.startsWith('size:')) {
422
+ const s = parseFloat(t.split(':')[1]);
423
+ if (!isNaN(s) && s >= 12 && s <= 48)
424
+ widget.size = s;
425
+ }
426
+ else if (t.startsWith('color:')) {
427
+ widget.color = t.slice('color:'.length);
428
+ }
429
+ else if (t === 'bold') {
430
+ widget.bold = true;
431
+ }
432
+ else if (t.startsWith('align:')) {
433
+ const a = t.split(':')[1]?.toLowerCase();
434
+ if (a === 'left' || a === 'center' || a === 'right')
435
+ widget.align = a;
436
+ }
437
+ else if (t === 'alert') {
438
+ widget.alert = true;
439
+ }
440
+ else if (t.startsWith('alertStyle:')) {
441
+ const s = t.split(':')[1]?.toLowerCase();
442
+ if (s === 'info' || s === 'warning' || s === 'error') {
443
+ widget.alert = true;
444
+ widget.alertStyle = s;
445
+ }
446
+ }
447
+ else {
448
+ plainTokens.push(t);
449
+ }
450
+ }
451
+ if (plainTokens.length === 0) {
452
+ return { success: false, error: "text requires content" };
453
+ }
454
+ if (plainTokens.length >= 2) {
455
+ widget.id = plainTokens[0];
456
+ widget.text = plainTokens[1];
457
+ }
458
+ else {
459
+ widget.text = plainTokens[0];
460
+ }
461
+ // id 缺省时用内容生成(col/编辑器定位需要唯一 id)
462
+ if (!widget.id)
463
+ widget.id = `text-${widget.text.slice(0, 8).replace(/\s+/g, '-')}`;
464
+ return { success: true, widget };
465
+ }
466
+ parseButton(tokens) {
467
+ if (tokens.length < 2) {
468
+ return { success: false, error: "button requires id" };
469
+ }
470
+ const widget = {
471
+ type: "button",
472
+ id: tokens[1]
473
+ };
474
+ // 第 3 个 token 若非 key:value 形式则为文字内容
475
+ for (let i = 2; i < tokens.length; i++) {
476
+ const t = tokens[i];
477
+ if (t.startsWith('color:')) {
478
+ widget.color = t.slice('color:'.length);
479
+ }
480
+ else if (t.startsWith('textColor:')) {
481
+ widget.textColor = t.slice('textColor:'.length);
482
+ }
483
+ else if (t.startsWith('size:')) {
484
+ const s = t.split(':')[1]?.toLowerCase();
485
+ if (s === 'small' || s === 'medium' || s === 'large')
486
+ widget.size = s;
487
+ }
488
+ else if (widget.label === undefined) {
489
+ widget.label = t;
490
+ }
491
+ else if (t === 'disabled') {
492
+ widget.disabled = true;
493
+ }
494
+ }
495
+ return { success: true, widget };
496
+ }
497
+ parseButtonGroup(tokens) {
498
+ const mods = this.extractFieldMods(tokens);
499
+ const plain = mods.plain;
500
+ if (plain.length < 2) {
501
+ return { success: false, error: "button-group requires id and choices array" };
502
+ }
503
+ try {
504
+ const choices = this.parseArray(plain[1]);
505
+ const widget = {
506
+ type: "button-group",
507
+ id: plain[0],
508
+ choices
509
+ };
510
+ if (plain.length > 2)
511
+ widget.default = plain[2];
512
+ if (mods.label)
513
+ widget.label = mods.label;
514
+ if (mods.hint)
515
+ widget.hint = mods.hint;
516
+ if (mods.required)
517
+ widget.required = true;
518
+ if (mods.disabled)
519
+ widget.disabled = true;
520
+ return { success: true, widget };
521
+ }
522
+ catch (error) {
523
+ return { success: false, error: `Invalid choices array: ${error}` };
524
+ }
525
+ }
526
+ parseSelect(tokens) {
527
+ const mods = this.extractFieldMods(tokens);
528
+ const plain = mods.plain;
529
+ if (plain.length < 2) {
530
+ return { success: false, error: "select requires id and choices array" };
531
+ }
532
+ try {
533
+ const choices = this.parseArray(plain[1]);
534
+ const widget = {
535
+ type: "select",
536
+ id: plain[0],
537
+ choices
538
+ };
539
+ if (mods.label)
540
+ widget.label = mods.label;
541
+ if (plain.length > 2)
542
+ widget.default = plain[2];
543
+ if (mods.hint)
544
+ widget.hint = mods.hint;
545
+ if (mods.required)
546
+ widget.required = true;
547
+ if (mods.url !== undefined)
548
+ widget.url = mods.url;
549
+ if (mods.searchable)
550
+ widget.searchable = true;
551
+ if (mods.disabled)
552
+ widget.disabled = true;
553
+ return { success: true, widget };
554
+ }
555
+ catch (error) {
556
+ return { success: false, error: `Invalid choices array: ${error}` };
557
+ }
558
+ }
559
+ parseSelectMulti(tokens) {
560
+ const mods = this.extractFieldMods(tokens);
561
+ const plain = mods.plain;
562
+ if (plain.length < 2) {
563
+ return { success: false, error: "select-multi requires id and choices array" };
564
+ }
565
+ try {
566
+ // 对象数组格式 [{key|value,label} ...]:绕过 parseArray(其逗号/空格拆分会拆碎 JSON),
567
+ // 走容错 JSON 解析;普通字符串数组仍走 parseArray
568
+ const choices = this.parseObjectChoices(plain[1]);
569
+ const widget = {
570
+ type: "select-multi",
571
+ id: plain[0],
572
+ choices
573
+ };
574
+ if (mods.label)
575
+ widget.label = mods.label;
576
+ if (plain.length > 2) {
577
+ if (plain[2].startsWith('[')) {
578
+ widget.default = this.parseArray(plain[2]);
579
+ }
580
+ else {
581
+ widget.default = plain[2];
582
+ }
583
+ }
584
+ if (mods.hint)
585
+ widget.hint = mods.hint;
586
+ if (mods.required)
587
+ widget.required = true;
588
+ if (mods.message)
589
+ widget.message = mods.message;
590
+ if (mods.url !== undefined)
591
+ widget.url = mods.url;
592
+ if (mods.disabled)
593
+ widget.disabled = true;
594
+ if (mods.direction)
595
+ widget.direction = mods.direction;
596
+ if (mods.columns !== undefined)
597
+ widget.columns = mods.columns;
598
+ return { success: true, widget };
599
+ }
600
+ catch (error) {
601
+ return { success: false, error: `Invalid format: ${error}` };
602
+ }
603
+ }
604
+ /** 解析单选组件:radio-group id [choices] [default] [button] [label:xx] [hint:xx] [required]
605
+ * [message:xx] [url:xx] [direction:horizontal|vertical] [columns:N] [disabled]
606
+ * choices 兼容字符串数组(key:label)与对象数组({key|value,label});default 为单值;
607
+ * button 标记切换为按钮组样式(参考 antd Radio optionType:button) */
608
+ parseRadioGroup(tokens) {
609
+ const mods = this.extractFieldMods(tokens);
610
+ const plain = mods.plain;
611
+ if (plain.length < 2) {
612
+ return { success: false, error: "radio-group requires id and choices array" };
613
+ }
614
+ try {
615
+ // 对象数组格式与 select-multi 一致:走容错 JSON 解析
616
+ const choices = this.parseObjectChoices(plain[1]);
617
+ const widget = {
618
+ type: "radio-group",
619
+ id: plain[0],
620
+ choices
621
+ };
622
+ if (mods.label)
623
+ widget.label = mods.label;
624
+ // default 为单值(数组写法取第一项,容错)
625
+ if (plain.length > 2) {
626
+ if (plain[2].startsWith('[')) {
627
+ const arr = this.parseArray(plain[2]);
628
+ if (arr.length > 0)
629
+ widget.default = arr[0];
630
+ }
631
+ else {
632
+ widget.default = plain[2];
633
+ }
634
+ }
635
+ if (mods.button)
636
+ widget.button = true;
637
+ if (mods.hint)
638
+ widget.hint = mods.hint;
639
+ if (mods.required)
640
+ widget.required = true;
641
+ if (mods.message)
642
+ widget.message = mods.message;
643
+ if (mods.url !== undefined)
644
+ widget.url = mods.url;
645
+ if (mods.disabled)
646
+ widget.disabled = true;
647
+ if (mods.direction)
648
+ widget.direction = mods.direction;
649
+ if (mods.columns !== undefined)
650
+ widget.columns = mods.columns;
651
+ return { success: true, widget };
652
+ }
653
+ catch (error) {
654
+ return { success: false, error: `Invalid format: ${error}` };
655
+ }
656
+ }
657
+ /** 解析 select-multi choices:token 以 [{ 开头视为对象数组([{key|value,label} ...]),
658
+ * 容错 JSON 解析(单引号、未引号 key/值、尾逗号、元素间空格分隔、截断补齐)并归一化为
659
+ * {value,label} 数组(key 作为 value 别名,value > key);否则按普通字符串数组解析 */
660
+ parseObjectChoices(token) {
661
+ const trimmed = token.trim();
662
+ // [{ 开头(含 [ { 间空白)视为对象数组
663
+ if (!/^\[\s*\{/.test(trimmed)) {
664
+ return this.parseArray(token);
665
+ }
666
+ if (trimmed.length <= 4)
667
+ return [];
668
+ let parsed;
669
+ try {
670
+ parsed = JSON.parse(trimmed);
671
+ }
672
+ catch {
673
+ parsed = this.repairObjectChoices(trimmed);
674
+ }
675
+ if (!Array.isArray(parsed)) {
676
+ throw new Error('对象数组 choices 必须是 [{...} ...] 形式');
677
+ }
678
+ // 归一化:value > key 取值,label 缺省回落到 value;字符串条目 value=label
679
+ const items = [];
680
+ for (const item of parsed) {
681
+ if (item === null || item === undefined)
682
+ continue;
683
+ if (typeof item === 'string' || typeof item === 'number') {
684
+ const s = String(item);
685
+ if (s)
686
+ items.push({ value: s, label: s });
687
+ continue;
688
+ }
689
+ if (typeof item !== 'object')
690
+ continue;
691
+ const value = item.value ?? item.key;
692
+ if (value === undefined || value === null || value === '')
693
+ continue;
694
+ const valueStr = String(value);
695
+ const label = item.label !== undefined && item.label !== null && item.label !== ''
696
+ ? String(item.label)
697
+ : valueStr;
698
+ items.push({ value: valueStr, label });
699
+ }
700
+ return items;
701
+ }
702
+ /** 容错修复对象数组 choices 文本(整体含外层 []),返回解析结果(失败抛错)。
703
+ * 支持的 AI 风格写法:单引号、\xxx\ 反斜杠引号、未引号 key、未引号字符串值、
704
+ * 尾逗号、元素间空格分隔(} { → },{)、输入截断自动补齐 */
705
+ repairObjectChoices(raw) {
706
+ let s = raw;
707
+ // 单引号 → 双引号;\xxx\ 反斜杠引号 → "xxx"(成对包裹字符串时)
708
+ s = s.replace(/'([^']*)'/g, '"$1"');
709
+ s = s.replace(/\\([^\\]*)\\/g, '"$1"');
710
+ // 未加引号的 key:{key: / ,key: → 补双引号
711
+ s = s.replace(/([{,]\s*)([A-Za-z_$][\w$-]*)\s*:/g, '$1"$2":');
712
+ // 未加引号的字符串值::a, / :A} / :中文, → 补双引号(true/false/null 保持字面量;中文开头的裸值同样支持)
713
+ s = s.replace(/(:\s*)([A-Za-z_$\u4e00-\u9fa5][\w$\u4e00-\u9fa5-]*)(\s*[,}\]])/g, (m, p1, p2, p3) => p2 === 'true' || p2 === 'false' || p2 === 'null' ? m : `${p1}"${p2}"${p3}`);
714
+ // 去掉最后一个元素后的多余逗号(逗号后直接 ] 或 })
715
+ s = s.replace(/,(\s*[}\]])/g, '$1');
716
+ // 数组元素间缺失的逗号:DSL 风格用空格分隔(} { / } " → },{ / },")
717
+ s = s.replace(/\}\s*([{"])/g, '},$1');
718
+ // 字符串中途截断(末尾缺右引号):补上
719
+ let inQuote = false;
720
+ for (const c of s) {
721
+ if (inQuote) {
722
+ if (c === '"')
723
+ inQuote = false;
724
+ continue;
725
+ }
726
+ if (c === '"')
727
+ inQuote = true;
728
+ }
729
+ if (inQuote)
730
+ s += '"';
731
+ // 末尾悬挂逗号/冒号/左括号清除(截断残尾)
732
+ s = s.replace(/,\s*$/, '').replace(/:\s*$/, '').replace(/([{[]\s*)$/, '');
733
+ // 栈式配平补齐缺失的闭合符(正确处理 {/[ 交错,流式输入中途状态)
734
+ const stack = [];
735
+ let inQ = false;
736
+ for (const c of s) {
737
+ if (inQ) {
738
+ if (c === '"')
739
+ inQ = false;
740
+ continue;
741
+ }
742
+ if (c === '"')
743
+ inQ = true;
744
+ else if (c === '{' || c === '[')
745
+ stack.push(c);
746
+ else if (c === '}' || c === ']')
747
+ stack.pop();
748
+ }
749
+ while (stack.length)
750
+ s += stack.pop() === '{' ? '}' : ']';
751
+ try {
752
+ return JSON.parse(s);
753
+ }
754
+ catch (e) {
755
+ throw new Error(`对象数组 choices 不是合法 JSON:${e instanceof Error ? e.message : e}`);
756
+ }
757
+ }
758
+ /** 解析级联选择器:cascade id [路径数组] [default路径] [label:xx] [hint:xx] [required] [disabled] [placeholder:xx]
759
+ * [url:xx] [data:[{...}]]
760
+ * 静态数据源兼容共存:
761
+ * - choices 扁平路径数组:每项用 / 分隔层级,每段支持 key:label(如 zj:浙江/hz:杭州)
762
+ * - data 嵌套对象树:CascadeNode[],如 [{"value":"zj","label":"浙江","children":[...]}](优先于 choices)
763
+ * - data/url 单独出现时(无 choices)也合法:choices 退化为 [] */
764
+ parseCascade(tokens) {
765
+ const mods = this.extractFieldMods(tokens);
766
+ const plain = mods.plain;
767
+ if (plain.length < 2 && mods.data === undefined && mods.url === undefined) {
768
+ return { success: false, error: "cascade requires id and choices array" };
769
+ }
770
+ try {
771
+ const choices = plain.length > 1 ? this.parseArray(plain[1]) : [];
772
+ const widget = {
773
+ type: "cascade",
774
+ id: plain[0],
775
+ choices
776
+ };
777
+ // 嵌套对象树数据源(可选,兼容共存:存在时优先于 choices 扁平路径)
778
+ if (mods.data !== undefined) {
779
+ widget.data = this.parseCascadeNodes(mods.data);
780
+ }
781
+ // 远端数据源 url(可选)
782
+ if (mods.url !== undefined)
783
+ widget.url = mods.url;
784
+ // 懒加载模式(url 存在时生效):首次只拉根级,点击父级后 url?parent=<value> 逐级请求
785
+ if (mods.lazy)
786
+ widget.lazy = true;
787
+ if (mods.label)
788
+ widget.label = mods.label;
789
+ if (plain.length > 2)
790
+ widget.default = plain[2];
791
+ if (mods.hint)
792
+ widget.hint = mods.hint;
793
+ if (mods.required)
794
+ widget.required = true;
795
+ if (mods.message)
796
+ widget.message = mods.message;
797
+ if (mods.placeholder)
798
+ widget.placeholder = mods.placeholder;
799
+ if (mods.disabled)
800
+ widget.disabled = true;
801
+ return { success: true, widget };
802
+ }
803
+ catch (error) {
804
+ return { success: false, error: `Invalid choices array: ${error}` };
805
+ }
806
+ }
807
+ /** 解析树形选择器:tree-select id [路径数组] [default值] [label:xx] [hint:xx] [required] [disabled]
808
+ * [placeholder:xx] [url:xx] [data:[{...}]]
809
+ * [multiple] [checkStrictly] [strategy:all|parent|child] [maxTagCount:N] [treeLine] [searchable]
810
+ * 静态数据源兼容共存(与 cascade 一致);多选/单选对齐 antd(multiple 开启多选,默认单选);
811
+ * searchable 显式开启搜索(默认关闭,对齐 antd showSearch 默认 false) */
812
+ parseTreeSelect(tokens) {
813
+ const mods = this.extractFieldMods(tokens);
814
+ const plain = mods.plain;
815
+ if (plain.length < 2 && mods.data === undefined && mods.url === undefined) {
816
+ return { success: false, error: "tree-select requires id and choices array" };
817
+ }
818
+ try {
819
+ const choices = plain.length > 1 ? this.parseArray(plain[1]) : [];
820
+ const widget = {
821
+ type: "tree-select",
822
+ id: plain[0],
823
+ choices
824
+ };
825
+ // 嵌套对象树数据源(可选,优先于 choices)
826
+ if (mods.data !== undefined) {
827
+ widget.data = this.parseCascadeNodes(mods.data);
828
+ }
829
+ // 远端数据源
830
+ if (mods.url !== undefined)
831
+ widget.url = mods.url;
832
+ // 懒加载模式
833
+ if (mods.lazy)
834
+ widget.lazy = true;
835
+ // 多选模式(antd multiple,默认单选)
836
+ if (mods.multiple)
837
+ widget.multiple = true;
838
+ // 父子节点选中关联(antd checkStrictly:true 为不关联)
839
+ if (mods.checkStrictly)
840
+ widget.checkStrictly = true;
841
+ // 选中值回显策略(antd showCheckedStrategy)
842
+ if (mods.checkedStrategy)
843
+ widget.showCheckedStrategy = mods.checkedStrategy;
844
+ // 触发器最多显示的标签数(antd maxTagCount)
845
+ if (mods.maxTagCount !== undefined)
846
+ widget.maxTagCount = mods.maxTagCount;
847
+ // 树连接线(antd treeLine)
848
+ if (mods.treeLine)
849
+ widget.treeLine = true;
850
+ // 可搜索(antd showSearch,默认关闭)
851
+ if (mods.searchable)
852
+ widget.searchable = true;
853
+ if (mods.label)
854
+ widget.label = mods.label;
855
+ if (plain.length > 2) {
856
+ const rawDefault = plain[2];
857
+ // 多选模式支持数组 default: ["v1" "v2"];单选支持单值
858
+ if (rawDefault.startsWith('[')) {
859
+ try {
860
+ widget.default = this.parseArray(rawDefault);
861
+ }
862
+ catch { /* 忽略 */ }
863
+ }
864
+ else {
865
+ widget.default = rawDefault;
866
+ }
867
+ }
868
+ if (mods.hint)
869
+ widget.hint = mods.hint;
870
+ if (mods.required)
871
+ widget.required = true;
872
+ if (mods.message)
873
+ widget.message = mods.message;
874
+ if (mods.placeholder)
875
+ widget.placeholder = mods.placeholder;
876
+ if (mods.disabled)
877
+ widget.disabled = true;
878
+ return { success: true, widget };
879
+ }
880
+ catch (error) {
881
+ return { success: false, error: `Invalid tree-select: ${error}` };
882
+ }
883
+ }
884
+ /** 把 data:[...] token 解析为嵌套对象树(CascadeNode[])。token 形如 [{...}] / {...}(data: 前缀已被 extractFieldMods 剥除)。
885
+ * 先按严格 JSON 解析;失败再容错修复 AI 风格写法:
886
+ * - 单引号 → 双引号;反斜杠引号 \xxx\ → "xxx"(键值两侧成对出现时)
887
+ * - 未加引号的 key({value: / ,value: / ,children: → 补双引号)
888
+ * - 行尾/值后多余逗号;输入被截断(末尾缺引号/]/})自动补齐
889
+ * - value/label 支持数字字面量;字段名兼容 key(key 作为 value 别名,由渲染端 nodesToTree 处理)
890
+ * - 顶层是单个对象({...} 或 {data:[...]} 包裹)时提取其内部数组 */
891
+ parseCascadeNodes(token) {
892
+ const trimmed = token.trim();
893
+ if (!trimmed.startsWith('[') && !trimmed.startsWith('{')) {
894
+ throw new Error('data 树必须是数组或对象');
895
+ }
896
+ if (trimmed.length <= 2)
897
+ return [];
898
+ // 先按严格 JSON 解析(合法输入不经过任何容错改写,含 \\ 转义的字符串不受影响)
899
+ let parsed;
900
+ try {
901
+ parsed = JSON.parse(trimmed);
902
+ }
903
+ catch {
904
+ parsed = this.repairCascadeData(trimmed);
905
+ }
906
+ if (parsed === undefined || parsed === null) {
907
+ throw new Error('data 树不是合法 JSON');
908
+ }
909
+ // 归一化:数组直接用;单对象 → {data|options|...} 包裹取数组或自身入数组
910
+ if (!Array.isArray(parsed)) {
911
+ const list = parsed?.data ?? parsed?.options ?? parsed?.choices ?? parsed?.items ?? parsed?.tree ?? parsed?.treeData;
912
+ if (Array.isArray(list))
913
+ return list;
914
+ if (typeof parsed === 'object')
915
+ return [parsed];
916
+ throw new Error('data 树必须是数组');
917
+ }
918
+ return parsed;
919
+ }
920
+ /** 容错修复 AI 风格/截断的 data 树文本,返回解析结果(失败抛错) */
921
+ repairCascadeData(raw) {
922
+ let s = raw;
923
+ // 单引号 → 双引号;\xxx\ 反斜杠引号 → "xxx"(成对包裹字符串时)
924
+ s = s.replace(/'([^']*)'/g, '"$1"');
925
+ s = s.replace(/\\([^\\]*)\\/g, '"$1"');
926
+ // 未加引号的 key:{value: / ,value: / ,children: → 补双引号
927
+ s = s.replace(/([{,]\s*)([A-Za-z_$][\w$-]*)\s*:/g, '$1"$2":');
928
+ // 去掉最后一个元素后的多余逗号(逗号后直接 ] 或 })
929
+ s = s.replace(/,(\s*[}\]])/g, '$1');
930
+ // 字符串中途截断(末尾缺右引号):补上
931
+ let inQuote = false;
932
+ for (const c of s) {
933
+ if (inQuote) {
934
+ if (c === '"')
935
+ inQuote = false;
936
+ continue;
937
+ }
938
+ if (c === '"')
939
+ inQuote = true;
940
+ }
941
+ if (inQuote)
942
+ s += '"';
943
+ // 末尾悬挂逗号/冒号/左括号清除(截断的 "label:\test\," / "children:[" 残尾)
944
+ s = s.replace(/,\s*$/, '').replace(/:\s*$/, '').replace(/([{[]\s*)$/, '');
945
+ // 栈式配平补齐缺失的闭合符(正确处理 {/[ 交错,流式输入中途状态)
946
+ const stack = [];
947
+ let inQ = false;
948
+ for (const c of s) {
949
+ if (inQ) {
950
+ if (c === '"')
951
+ inQ = false;
952
+ continue;
953
+ }
954
+ if (c === '"')
955
+ inQ = true;
956
+ else if (c === '{' || c === '[')
957
+ stack.push(c);
958
+ else if (c === '}' || c === ']')
959
+ stack.pop();
960
+ }
961
+ while (stack.length)
962
+ s += stack.pop() === '{' ? '}' : ']';
963
+ try {
964
+ return JSON.parse(s);
965
+ }
966
+ catch (e) {
967
+ throw new Error(`data 树不是合法 JSON:${e instanceof Error ? e.message : e}`);
968
+ }
969
+ }
970
+ parseSlider(tokens) {
971
+ const mods = this.extractFieldMods(tokens);
972
+ const plain = mods.plain;
973
+ if (plain.length < 4) {
974
+ return { success: false, error: "slider requires id, min, max, step" };
975
+ }
976
+ const min = parseFloat(plain[1]);
977
+ const max = parseFloat(plain[2]);
978
+ const step = parseFloat(plain[3]);
979
+ if (isNaN(min) || isNaN(max) || isNaN(step)) {
980
+ return { success: false, error: "min, max, and step must be numbers" };
981
+ }
982
+ const widget = {
983
+ type: "slider",
984
+ id: plain[0],
985
+ min,
986
+ max,
987
+ step
988
+ };
989
+ if (mods.label)
990
+ widget.label = mods.label;
991
+ if (plain.length > 4) {
992
+ const defaultVal = parseFloat(plain[4]);
993
+ if (!isNaN(defaultVal)) {
994
+ widget.default = defaultVal;
995
+ }
996
+ }
997
+ if (mods.hint)
998
+ widget.hint = mods.hint;
999
+ if (mods.required)
1000
+ widget.required = true;
1001
+ if (mods.disabled)
1002
+ widget.disabled = true;
1003
+ return { success: true, widget };
1004
+ }
1005
+ /** 解析开关:switch id [true|false] [label:xx] [checkedText:xx] [unCheckedText:xx]
1006
+ * [hint:xx] [required] [disabled](default 位置参数接受 true/false 字面量) */
1007
+ parseSwitch(tokens) {
1008
+ const mods = this.extractFieldMods(tokens);
1009
+ const plain = mods.plain;
1010
+ if (plain.length < 1) {
1011
+ return { success: false, error: "switch requires id" };
1012
+ }
1013
+ const widget = {
1014
+ type: "switch",
1015
+ id: plain[0]
1016
+ };
1017
+ // default 位置参数:true/false 字面量(大小写不敏感),其他值忽略
1018
+ if (plain.length > 1) {
1019
+ const d = plain[1].toLowerCase();
1020
+ if (d === 'true')
1021
+ widget.default = true;
1022
+ else if (d === 'false')
1023
+ widget.default = false;
1024
+ }
1025
+ if (mods.label)
1026
+ widget.label = mods.label;
1027
+ if (mods.checkedText)
1028
+ widget.checkedText = mods.checkedText;
1029
+ if (mods.unCheckedText)
1030
+ widget.unCheckedText = mods.unCheckedText;
1031
+ if (mods.hint)
1032
+ widget.hint = mods.hint;
1033
+ if (mods.required)
1034
+ widget.required = true;
1035
+ if (mods.disabled)
1036
+ widget.disabled = true;
1037
+ return { success: true, widget };
1038
+ }
1039
+ /** 解析日期选择框:date-picker id [default] [picker:datetime] [range] [format:YYYY-MM-DD]
1040
+ * [disableBeforeNow] [disableAfterNow] [placeholder:xx] [label:xx] [hint:xx] [required]
1041
+ * default 接受时间戳(毫秒数字)或格式化时间字符串;range 模式为 [start end] 数组 */
1042
+ parseDatePicker(tokens) {
1043
+ // 先剔除 date-picker 专属修饰符,剩余 token 交给 extractFieldMods(id/default/label/hint/placeholder 等)
1044
+ const ownPrefixes = ['picker:', 'format:'];
1045
+ const ownFlags = new Set(['range', 'disableBeforeNow', 'disableAfterNow']);
1046
+ const rest = [tokens[0]];
1047
+ for (let i = 1; i < tokens.length; i++) {
1048
+ const t = tokens[i];
1049
+ if (ownPrefixes.some(p => t.startsWith(p)) || ownFlags.has(t))
1050
+ continue;
1051
+ rest.push(t);
1052
+ }
1053
+ const mods = this.extractFieldMods(rest);
1054
+ if (mods.plain.length < 1) {
1055
+ return { success: false, error: "date-picker requires at least an id" };
1056
+ }
1057
+ const widget = {
1058
+ type: "date-picker",
1059
+ id: mods.plain[0]
1060
+ };
1061
+ // 剩余位置参数:像日期(时间戳/日期字符串/范围数组)则当 default,否则当 label(与 text-input 等字段约定一致)
1062
+ // date-picker id "标签" 或 date-picker id 1755640800000 均可;label 之后的位置参数为 default
1063
+ const dateLike = (t) => t.startsWith('[') || /^-?\d+$/.test(t) || /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/.test(t);
1064
+ const restPlain = mods.plain.slice(1);
1065
+ // token(时间戳/日期串/范围数组)→ default 值;时间戳转 Number,数组走 parseArray
1066
+ const toDefault = (d) => {
1067
+ if (d.startsWith('[')) {
1068
+ try {
1069
+ return this.parseArray(d).map((v) => (/^-?\d+$/.test(v) ? Number(v) : v));
1070
+ }
1071
+ catch {
1072
+ return undefined;
1073
+ }
1074
+ }
1075
+ return /^-?\d+$/.test(d) ? Number(d) : d;
1076
+ };
1077
+ const defaultIdx = restPlain.findIndex(dateLike);
1078
+ if (defaultIdx === 0) {
1079
+ const v = toDefault(restPlain[0]);
1080
+ if (v !== undefined)
1081
+ widget.default = v;
1082
+ }
1083
+ else if (defaultIdx === 1) {
1084
+ // date-picker id "标签" "2026-08-20"(或 range 数组 ["a", "b"])
1085
+ widget.label = restPlain[0];
1086
+ const v = toDefault(restPlain[1]);
1087
+ if (v !== undefined)
1088
+ widget.default = v;
1089
+ }
1090
+ else if (defaultIdx === 2) {
1091
+ // date-picker id "标签" "默认值" "2026-08-20":中间 token 当 label 前缀文案
1092
+ widget.label = restPlain[0] + restPlain[1];
1093
+ const v = toDefault(restPlain[2]);
1094
+ if (v !== undefined)
1095
+ widget.default = v;
1096
+ }
1097
+ else if (defaultIdx === -1 && restPlain.length > 0) {
1098
+ // 无 default:唯一的位置参数是 label
1099
+ widget.label = restPlain[0];
1100
+ }
1101
+ // date-picker 专属 key:value / 布尔修饰符
1102
+ for (let i = 1; i < tokens.length; i++) {
1103
+ const t = tokens[i];
1104
+ if (t === 'range') {
1105
+ widget.range = true;
1106
+ }
1107
+ else if (t === 'disableBeforeNow') {
1108
+ widget.disableBeforeNow = true;
1109
+ }
1110
+ else if (t === 'disableAfterNow') {
1111
+ widget.disableAfterNow = true;
1112
+ }
1113
+ else if (t.startsWith('picker:')) {
1114
+ const p = t.split(':')[1]?.toLowerCase();
1115
+ if (p === 'date' || p === 'datetime')
1116
+ widget.picker = p;
1117
+ }
1118
+ else if (t.startsWith('format:')) {
1119
+ widget.format = t.slice('format:'.length);
1120
+ }
1121
+ }
1122
+ if (mods.label)
1123
+ widget.label = mods.label;
1124
+ if (mods.hint)
1125
+ widget.hint = mods.hint;
1126
+ if (mods.required)
1127
+ widget.required = true;
1128
+ if (mods.message)
1129
+ widget.message = mods.message;
1130
+ if (mods.placeholder)
1131
+ widget.placeholder = mods.placeholder;
1132
+ if (mods.disabled)
1133
+ widget.disabled = true;
1134
+ return { success: true, widget };
1135
+ }
1136
+ /** 解析数字输入框:number-input id [label] [default] [min:N] [max:N] [step:N] [precision:N]
1137
+ * [placeholder:xx] [label:xx] [hint:xx] [required] [message:xx]
1138
+ * label/default 均为位置参数:像数字(整数/小数/负数)的当 default,其余当 label(与 date-picker 约定一致) */
1139
+ parseNumberInput(tokens) {
1140
+ // 先剔除 number-input 专属修饰符(min:/max:/step:/precision: 前缀),剩余交给 extractFieldMods
1141
+ const ownPrefixes = ['min:', 'max:', 'step:', 'precision:'];
1142
+ const rest = [tokens[0]];
1143
+ for (let i = 1; i < tokens.length; i++) {
1144
+ const t = tokens[i];
1145
+ if (ownPrefixes.some(p => t.startsWith(p)))
1146
+ continue;
1147
+ rest.push(t);
1148
+ }
1149
+ const mods = this.extractFieldMods(rest);
1150
+ if (mods.plain.length < 1) {
1151
+ return { success: false, error: "number-input requires at least an id" };
1152
+ }
1153
+ const widget = {
1154
+ type: "number-input",
1155
+ id: mods.plain[0]
1156
+ };
1157
+ // 剩余位置参数:纯数字(含负数/小数)当 default,其余当 label;label 之后的数字为 default
1158
+ const numLike = (t) => /^-?\d+(\.\d+)?$/.test(t);
1159
+ const restPlain = mods.plain.slice(1);
1160
+ const defaultIdx = restPlain.findIndex(numLike);
1161
+ if (defaultIdx === 0) {
1162
+ widget.default = Number(restPlain[0]);
1163
+ }
1164
+ else if (defaultIdx === 1) {
1165
+ // number-input id "标签" 5
1166
+ widget.label = restPlain[0];
1167
+ widget.default = Number(restPlain[1]);
1168
+ }
1169
+ else if (defaultIdx === -1 && restPlain.length > 0) {
1170
+ // 无 default:唯一的位置参数是 label
1171
+ widget.label = restPlain[0];
1172
+ }
1173
+ // number-input 专属 key:value 修饰符
1174
+ for (let i = 1; i < tokens.length; i++) {
1175
+ const t = tokens[i];
1176
+ if (t.startsWith('min:')) {
1177
+ const n = parseFloat(t.slice('min:'.length));
1178
+ if (!isNaN(n))
1179
+ widget.min = n;
1180
+ }
1181
+ else if (t.startsWith('max:')) {
1182
+ const n = parseFloat(t.slice('max:'.length));
1183
+ if (!isNaN(n))
1184
+ widget.max = n;
1185
+ }
1186
+ else if (t.startsWith('step:')) {
1187
+ const n = parseFloat(t.slice('step:'.length));
1188
+ if (!isNaN(n) && n > 0)
1189
+ widget.step = n;
1190
+ }
1191
+ else if (t.startsWith('precision:')) {
1192
+ const n = parseInt(t.slice('precision:'.length), 10);
1193
+ if (!isNaN(n) && n >= 0 && n <= 100)
1194
+ widget.precision = n;
1195
+ }
1196
+ }
1197
+ // default 钳制到 [min, max]
1198
+ if (widget.default !== undefined) {
1199
+ if (widget.min !== undefined && widget.default < widget.min)
1200
+ widget.default = widget.min;
1201
+ if (widget.max !== undefined && widget.default > widget.max)
1202
+ widget.default = widget.max;
1203
+ }
1204
+ if (mods.label)
1205
+ widget.label = mods.label;
1206
+ if (mods.hint)
1207
+ widget.hint = mods.hint;
1208
+ if (mods.required)
1209
+ widget.required = true;
1210
+ if (mods.message)
1211
+ widget.message = mods.message;
1212
+ if (mods.placeholder)
1213
+ widget.placeholder = mods.placeholder;
1214
+ if (mods.disabled)
1215
+ widget.disabled = true;
1216
+ return { success: true, widget };
1217
+ }
1218
+ /** 从字段行提取尾部 span:N 修饰符(类似 antd Col 的 span),返回 [剩余行, span] */
1219
+ extractFieldSpan(line) {
1220
+ const m = line.match(/(?:^|\s)span:(\d+)\s*$/);
1221
+ if (!m)
1222
+ return [line, undefined];
1223
+ const span = parseInt(m[1], 10);
1224
+ if (isNaN(span) || span < 1 || span > 24)
1225
+ return [line, undefined];
1226
+ return [line.slice(0, m.index).trimEnd(), span];
1227
+ }
1228
+ /** 从字段行提取尾部 when:字段id:值1,值2 修饰符(form 内联动显隐),返回 [剩余行, when] */
1229
+ extractFieldWhen(line) {
1230
+ const m = line.match(/(?:^|\s)when:([^\s:]+):([^\s]+)\s*$/);
1231
+ if (!m)
1232
+ return [line, undefined];
1233
+ const field = m[1];
1234
+ const values = m[2].split(',').map(v => v.trim()).filter(Boolean);
1235
+ if (!field || values.length === 0)
1236
+ return [line, undefined];
1237
+ return [line.slice(0, m.index).trimEnd(), { field, values }];
1238
+ }
1239
+ /** 解析 when 值段的比较运算符前缀(> >= < <= !=):返回 [op, 值列表]。
1240
+ * 无前缀或前缀后无有效值时返回 ['eq', 原值列表](向后兼容) */
1241
+ parseWhenOp(raw) {
1242
+ const m = raw.match(/^(>=|<=|!=|>|<)(.+)$/);
1243
+ if (!m)
1244
+ return ['eq', raw.split(',').map(v => v.trim()).filter(Boolean)];
1245
+ const values = m[2].split(',').map(v => v.trim()).filter(Boolean);
1246
+ if (values.length === 0)
1247
+ return ['eq', raw.split(',').map(v => v.trim()).filter(Boolean)];
1248
+ const opMap = { '>': 'gt', '>=': 'gte', '<': 'lt', '<=': 'lte', '!=': 'ne' };
1249
+ return [opMap[m[1]], values];
1250
+ }
1251
+ /** 循环提取字段行尾部的 when/when-any/span 修饰符(支持任意书写顺序),
1252
+ * 返回 [剩余行, span, when]。多个 when: 为 AND 组合;多个 when-any: 为 OR 组合;
1253
+ * 两组同时存在时取交集(all 且 any)。仅且组时保持对象/数组形态(向后兼容);
1254
+ * 含或组时输出 FieldWhenGroup { all, any } */
1255
+ extractFieldModifiers(line) {
1256
+ let rest = line;
1257
+ let span;
1258
+ // 先按行尾提取序(逆序)收集原始条件
1259
+ const rawAll = [];
1260
+ const rawAny = [];
1261
+ for (;;) {
1262
+ const before = rest;
1263
+ // when-any:field:v1,v2(或组,需先于 when: 匹配避免前缀截断);值段可带比较运算符前缀(when-any:field:>=18)
1264
+ const mAny = rest.match(/(?:^|\s)when-any:([^\s:]+):([^\s]+)\s*$/);
1265
+ if (mAny) {
1266
+ const field = mAny[1];
1267
+ const [op, values] = this.parseWhenOp(mAny[2]);
1268
+ if (field && values.length > 0) {
1269
+ rawAny.push(op === 'eq' ? { field, values } : { field, values, op });
1270
+ rest = rest.slice(0, mAny.index).trimEnd();
1271
+ }
1272
+ }
1273
+ const mWhen = rest.match(/(?:^|\s)when:([^\s:]+):([^\s]+)\s*$/);
1274
+ if (mWhen) {
1275
+ const field = mWhen[1];
1276
+ const [op, values] = this.parseWhenOp(mWhen[2]);
1277
+ if (field && values.length > 0) {
1278
+ rawAll.push(op === 'eq' ? { field, values } : { field, values, op });
1279
+ rest = rest.slice(0, mWhen.index).trimEnd();
1280
+ }
1281
+ }
1282
+ const mSpan = rest.match(/(?:^|\s)span:(\d+)\s*$/);
1283
+ if (mSpan) {
1284
+ const s = parseInt(mSpan[1], 10);
1285
+ if (!isNaN(s) && s >= 1 && s <= 24) {
1286
+ span = s;
1287
+ rest = rest.slice(0, mSpan.index).trimEnd();
1288
+ }
1289
+ }
1290
+ if (rest === before)
1291
+ break; // 无更多可提取的尾部修饰符
1292
+ }
1293
+ if (rawAll.length === 0 && rawAny.length === 0)
1294
+ return [rest, span, undefined];
1295
+ // 反转恢复书写顺序,再合并同字段 values(when:a:1 when:a:2 ≡ when:a:1,2)
1296
+ const mergeUnique = (raw) => {
1297
+ const merged = [];
1298
+ for (const { field, values, op } of raw) {
1299
+ const exist = merged.find(w => w.field === field);
1300
+ if (exist) {
1301
+ for (const v of values)
1302
+ if (!exist.values.includes(v))
1303
+ exist.values.push(v);
1304
+ }
1305
+ else {
1306
+ merged.push(op ? { field, values, op } : { field, values });
1307
+ }
1308
+ }
1309
+ return merged;
1310
+ };
1311
+ const whens = mergeUnique(rawAll.reverse());
1312
+ const anys = mergeUnique(rawAny.reverse());
1313
+ // 仅且组:保持对象/数组形态(向后兼容)
1314
+ if (anys.length === 0)
1315
+ return [rest, span, whens.length === 1 ? whens[0] : whens];
1316
+ // 含或组:输出分组形态
1317
+ const group = {};
1318
+ if (whens.length > 0)
1319
+ group.all = whens;
1320
+ group.any = anys;
1321
+ return [rest, span, group];
1322
+ }
1323
+ /** 判断字段首行是否为多行块组件(chart 系列)的起始行 */
1324
+ isMultiLineFieldStart(trimmedLine) {
1325
+ const first = trimmedLine.split(/\s+/)[0];
1326
+ return first === 'chart-line' || first === 'chart-bar' || first === 'chart-pie' || first === 'chart-scatter';
1327
+ }
1328
+ /** 从 "row" 行提取 gutter:N 参数(类似 antd Row 的 gutter),返回 [gutter] */
1329
+ parseRowGutter(rest) {
1330
+ const m = rest.match(/(?:^|\s)gutter:(\d+)(?:\.\d+)?\s*$/);
1331
+ if (!m)
1332
+ return undefined;
1333
+ const gutter = parseFloat(m[1]);
1334
+ return isNaN(gutter) || gutter < 0 ? undefined : gutter;
1335
+ }
1336
+ parseForm(tokens, fullInput) {
1337
+ if (tokens.length < 2) {
1338
+ return { success: false, error: "form requires at least an id" };
1339
+ }
1340
+ const lines = fullInput.split('\n');
1341
+ const firstLine = lines[0];
1342
+ const firstTokens = this.tokenize(firstLine);
1343
+ const widget = {
1344
+ type: "form",
1345
+ id: firstTokens[1],
1346
+ fields: []
1347
+ };
1348
+ // 首行参数解析:form id "submitLabel" [inline] [columns:N] [cancel:"取消"] [reset:"清空"] [align:right]
1349
+ // [layout:horizontal] [labelWidth:100] [contentWidth:400]
1350
+ for (let ti = 2; ti < firstTokens.length; ti++) {
1351
+ const t = firstTokens[ti];
1352
+ if (t === 'inline') {
1353
+ widget.inline = true;
1354
+ }
1355
+ else if (t.startsWith('inline:')) {
1356
+ widget.inline = t.split(':')[1]?.toLowerCase() === 'true';
1357
+ }
1358
+ else if (t.startsWith('columns:')) {
1359
+ widget.columns = parseInt(t.split(':')[1]);
1360
+ }
1361
+ else if (t.startsWith('cancel:')) {
1362
+ widget.cancelLabel = t.slice('cancel:'.length).replace(/^["']|["']$/g, '');
1363
+ }
1364
+ else if (t.startsWith('reset:')) {
1365
+ widget.resetLabel = t.slice('reset:'.length).replace(/^["']|["']$/g, '');
1366
+ }
1367
+ else if (t.startsWith('align:')) {
1368
+ const a = t.split(':')[1]?.toLowerCase();
1369
+ if (a === 'left' || a === 'center' || a === 'right')
1370
+ widget.buttonsAlign = a;
1371
+ }
1372
+ else if (t.startsWith('layout:')) {
1373
+ const l = t.split(':')[1]?.toLowerCase();
1374
+ if (l === 'horizontal' || l === 'vertical')
1375
+ widget.layout = l;
1376
+ }
1377
+ else if (t.startsWith('labelWidth:')) {
1378
+ const n = parseFloat(t.split(':')[1]);
1379
+ if (!isNaN(n) && n > 0)
1380
+ widget.labelWidth = n;
1381
+ }
1382
+ else if (t.startsWith('contentWidth:')) {
1383
+ const n = parseFloat(t.split(':')[1]);
1384
+ if (!isNaN(n) && n > 0)
1385
+ widget.contentWidth = n;
1386
+ }
1387
+ else if (t === 'disabled') {
1388
+ widget.disabled = true;
1389
+ }
1390
+ else if (!widget.submitLabel) {
1391
+ widget.submitLabel = t;
1392
+ }
1393
+ }
1394
+ // 扫描后续行:先找配置行(key:value),再处理 row 分组和普通 field
1395
+ let i = 1;
1396
+ let configDone = false;
1397
+ let currentRow = null;
1398
+ let currentRowGutter = undefined;
1399
+ let hasRows = false;
1400
+ for (; i < lines.length; i++) {
1401
+ const rawLine = lines[i];
1402
+ if (!rawLine.trim())
1403
+ continue;
1404
+ // 配置行:inline: true / columns: 3 / layout: horizontal / labelWidth: 100 / contentWidth: 400(必须在任何 row/field 之前)
1405
+ if (!configDone && rawLine.startsWith(' ') && !rawLine.startsWith(' ')) {
1406
+ const trimmed = rawLine.trim();
1407
+ if (trimmed.includes(':')) {
1408
+ const colonIdx = trimmed.indexOf(':');
1409
+ const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
1410
+ const val = trimmed.slice(colonIdx + 1).trim();
1411
+ // key 已 toLowerCase():labelWidth/contentWidth 的小写形式也纳入白名单,避免驼峰 key 永远不匹配
1412
+ if (['inline', 'columns', 'cancel', 'reset', 'align', 'layout', 'labelwidth', 'contentwidth', 'disabled'].includes(key)) {
1413
+ if (key === 'inline')
1414
+ widget.inline = val.toLowerCase() === 'true';
1415
+ if (key === 'columns')
1416
+ widget.columns = parseInt(val);
1417
+ if (key === 'cancel')
1418
+ widget.cancelLabel = val.replace(/^["']|["']$/g, '');
1419
+ if (key === 'reset')
1420
+ widget.resetLabel = val.replace(/^["']|["']$/g, '');
1421
+ if (key === 'align') {
1422
+ const a = val.toLowerCase();
1423
+ if (a === 'left' || a === 'center' || a === 'right')
1424
+ widget.buttonsAlign = a;
1425
+ }
1426
+ if (key === 'layout') {
1427
+ const l = val.toLowerCase();
1428
+ if (l === 'horizontal' || l === 'vertical')
1429
+ widget.layout = l;
1430
+ }
1431
+ if (key === 'labelwidth') {
1432
+ const n = parseFloat(val);
1433
+ if (!isNaN(n) && n > 0)
1434
+ widget.labelWidth = n;
1435
+ }
1436
+ if (key === 'contentwidth') {
1437
+ const n = parseFloat(val);
1438
+ if (!isNaN(n) && n > 0)
1439
+ widget.contentWidth = n;
1440
+ }
1441
+ if (key === 'disabled')
1442
+ widget.disabled = val.toLowerCase() === 'true';
1443
+ continue;
1444
+ }
1445
+ }
1446
+ configDone = true;
1447
+ }
1448
+ // row 分组(2 空格缩进,关键字 "row",可选 gutter:N)
1449
+ if (rawLine.startsWith(' ') && !rawLine.startsWith(' ')) {
1450
+ const rest = rawLine.slice(2).trim();
1451
+ if (rest === 'row' || rest.startsWith('row ') || rest.startsWith('row\t')) {
1452
+ // 保存上一个 row
1453
+ if (currentRow && currentRow.length > 0) {
1454
+ widget.rows = widget.rows || [];
1455
+ widget.rows.push({ ...(currentRowGutter !== undefined ? { gutter: currentRowGutter } : {}), fields: currentRow });
1456
+ }
1457
+ currentRow = [];
1458
+ currentRowGutter = this.parseRowGutter(rest);
1459
+ hasRows = true;
1460
+ configDone = true;
1461
+ continue;
1462
+ }
1463
+ }
1464
+ // row 内的字段(4 空格缩进)
1465
+ if (rawLine.startsWith(' ') && currentRow !== null) {
1466
+ let fieldLine = rawLine.slice(4).trim();
1467
+ // chart 等多行块组件:收集本字段块的所有行(更深的缩进 >= 6 空格或空行),块整体递归 parse
1468
+ if (this.isMultiLineFieldStart(fieldLine)) {
1469
+ const blockLines = [fieldLine];
1470
+ let j = i + 1;
1471
+ for (; j < lines.length; j++) {
1472
+ const next = lines[j];
1473
+ if (!next.trim()) {
1474
+ blockLines.push('');
1475
+ continue;
1476
+ }
1477
+ const nextIndent = next.length - next.trimStart().length;
1478
+ if (nextIndent >= 6) {
1479
+ blockLines.push(next.slice(6));
1480
+ }
1481
+ else {
1482
+ break;
1483
+ }
1484
+ }
1485
+ i = j - 1;
1486
+ // 从块首行提取尾部 when/span 修饰符(必须先于 parse,避免误触 chart 旧格式判定)
1487
+ let fieldSpan;
1488
+ let fieldWhen;
1489
+ [blockLines[0], fieldSpan, fieldWhen] = this.extractFieldModifiers(blockLines[0]);
1490
+ const fieldResult = this.parse(blockLines.join('\n').trim());
1491
+ if (fieldResult.success && fieldResult.widget) {
1492
+ if (fieldSpan !== undefined) {
1493
+ fieldResult.widget.span = fieldSpan;
1494
+ }
1495
+ if (fieldWhen !== undefined) {
1496
+ fieldResult.widget.when = fieldWhen;
1497
+ }
1498
+ currentRow.push(fieldResult.widget);
1499
+ }
1500
+ continue;
1501
+ }
1502
+ // 单行字段:提取尾部 when/span 修饰符(任意顺序)
1503
+ let fieldSpan;
1504
+ let fieldWhen;
1505
+ [fieldLine, fieldSpan, fieldWhen] = this.extractFieldModifiers(fieldLine);
1506
+ const fieldResult = this.parse(fieldLine);
1507
+ if (fieldResult.success && fieldResult.widget) {
1508
+ if (fieldSpan !== undefined) {
1509
+ fieldResult.widget.span = fieldSpan;
1510
+ }
1511
+ if (fieldWhen !== undefined) {
1512
+ fieldResult.widget.when = fieldWhen;
1513
+ }
1514
+ currentRow.push(fieldResult.widget);
1515
+ }
1516
+ continue;
1517
+ }
1518
+ // 普通字段(2 空格缩进,不在任何 row 内,支持尾部 when:field:values / span:N 修饰符)
1519
+ if (rawLine.startsWith(' ') && !rawLine.startsWith(' ') && !hasRows) {
1520
+ let fieldLine = rawLine.slice(2);
1521
+ // chart 等多行块组件:收集块的所有行(4 空格缩进或空行),块整体递归 parse
1522
+ if (this.isMultiLineFieldStart(fieldLine.trim())) {
1523
+ const blockLines = [fieldLine.trim()];
1524
+ let j = i + 1;
1525
+ for (; j < lines.length; j++) {
1526
+ const next = lines[j];
1527
+ if (!next.trim()) {
1528
+ blockLines.push('');
1529
+ continue;
1530
+ }
1531
+ const nextIndent = next.length - next.trimStart().length;
1532
+ if (nextIndent >= 4) {
1533
+ blockLines.push(next.slice(4));
1534
+ }
1535
+ else {
1536
+ break;
1537
+ }
1538
+ }
1539
+ i = j - 1;
1540
+ // 从块首行提取尾部 when/span 修饰符(必须先于 parse,避免误触 chart 旧格式判定)
1541
+ let fieldSpan;
1542
+ let fieldWhen;
1543
+ [blockLines[0], fieldSpan, fieldWhen] = this.extractFieldModifiers(blockLines[0]);
1544
+ const fieldResult = this.parse(blockLines.join('\n').trim());
1545
+ if (fieldResult.success && fieldResult.widget) {
1546
+ if (fieldSpan !== undefined) {
1547
+ fieldResult.widget.span = fieldSpan;
1548
+ }
1549
+ if (fieldWhen !== undefined) {
1550
+ fieldResult.widget.when = fieldWhen;
1551
+ }
1552
+ widget.fields.push(fieldResult.widget);
1553
+ }
1554
+ continue;
1555
+ }
1556
+ let fieldSpan;
1557
+ let fieldWhen;
1558
+ [fieldLine, fieldSpan, fieldWhen] = this.extractFieldModifiers(fieldLine);
1559
+ const fieldResult = this.parse(fieldLine);
1560
+ if (fieldResult.success && fieldResult.widget) {
1561
+ if (fieldSpan !== undefined) {
1562
+ fieldResult.widget.span = fieldSpan;
1563
+ }
1564
+ if (fieldWhen !== undefined) {
1565
+ fieldResult.widget.when = fieldWhen;
1566
+ }
1567
+ widget.fields.push(fieldResult.widget);
1568
+ }
1569
+ }
1570
+ }
1571
+ // 收尾:保存最后一个 row
1572
+ if (currentRow && currentRow.length > 0) {
1573
+ widget.rows = widget.rows || [];
1574
+ widget.rows.push({ ...(currentRowGutter !== undefined ? { gutter: currentRowGutter } : {}), fields: currentRow });
1575
+ }
1576
+ // 如果有 rows,也把所有 row 的 fields 合并到 fields(向后兼容)
1577
+ if (widget.rows && widget.rows.length > 0) {
1578
+ widget.fields = widget.rows.map((r) => Array.isArray(r) ? r : (r.fields || [])).flat();
1579
+ }
1580
+ return { success: true, widget };
1581
+ }
1582
+ /** 解析独立栅格组件:
1583
+ * row [id] [gutter:N] [justify:left|center|right|between] [align:top|middle|bottom]
1584
+ * col [span:N] [offset:N] [align:top|middle|bottom]
1585
+ * <任意 widget DSL>
1586
+ */
1587
+ parseRow(tokens, fullInput) {
1588
+ const lines = fullInput.split('\n');
1589
+ const firstTokens = this.tokenize(lines[0]);
1590
+ const widget = {
1591
+ type: "row",
1592
+ cols: []
1593
+ };
1594
+ // 首行参数:row [id] gutter:N justify:xx align:xx
1595
+ for (let ti = 1; ti < firstTokens.length; ti++) {
1596
+ const t = firstTokens[ti];
1597
+ if (t.startsWith('gutter:')) {
1598
+ const g = parseFloat(t.split(':')[1]);
1599
+ if (!isNaN(g) && g >= 0)
1600
+ widget.gutter = g;
1601
+ }
1602
+ else if (t.startsWith('justify:')) {
1603
+ const j = t.split(':')[1]?.toLowerCase();
1604
+ if (j === 'left' || j === 'center' || j === 'right' || j === 'between')
1605
+ widget.justify = j;
1606
+ }
1607
+ else if (t.startsWith('align:')) {
1608
+ const a = t.split(':')[1]?.toLowerCase();
1609
+ if (a === 'top' || a === 'middle' || a === 'bottom')
1610
+ widget.align = a;
1611
+ }
1612
+ else if (!t.includes(':') && !widget.id) {
1613
+ widget.id = t;
1614
+ }
1615
+ }
1616
+ // 扫描后续行:col 行(2 空格缩进)开新列,col 内(4 空格缩进)为子组件
1617
+ let currentCol = null;
1618
+ let colLine = 0;
1619
+ for (let i = 1; i < lines.length; i++) {
1620
+ const rawLine = lines[i];
1621
+ if (!rawLine.trim())
1622
+ continue;
1623
+ // col 行:2 空格缩进,"col" 开头
1624
+ if (rawLine.startsWith(' ') && !rawLine.startsWith(' ') && !rawLine.startsWith('\t')) {
1625
+ const rest = rawLine.slice(2).trim();
1626
+ if (rest === 'col' || rest.startsWith('col ') || rest.startsWith('col\t')) {
1627
+ currentCol = {};
1628
+ colLine = i;
1629
+ // col 参数:span:N offset:N align:xx
1630
+ for (const m of rest.slice(3).trim().split(/\s+/).filter(Boolean)) {
1631
+ if (m.startsWith('span:')) {
1632
+ const s = parseInt(m.split(':')[1], 10);
1633
+ if (!isNaN(s) && s >= 1 && s <= 24)
1634
+ currentCol.span = s;
1635
+ }
1636
+ else if (m.startsWith('offset:')) {
1637
+ const o = parseInt(m.split(':')[1], 10);
1638
+ if (!isNaN(o) && o >= 0 && o <= 23)
1639
+ currentCol.offset = o;
1640
+ }
1641
+ else if (m.startsWith('align:')) {
1642
+ const a = m.split(':')[1]?.toLowerCase();
1643
+ if (a === 'top' || a === 'middle' || a === 'bottom')
1644
+ currentCol.align = a;
1645
+ }
1646
+ }
1647
+ widget.cols.push(currentCol);
1648
+ continue;
1649
+ }
1650
+ }
1651
+ // col 内的子组件行(4 空格缩进):按缩进收集完整 DSL 块(chart/quiz/form 等多行组件)
1652
+ if (rawLine.startsWith(' ') && currentCol !== null && !currentCol.widget) {
1653
+ const subLines = [rawLine.slice(4)];
1654
+ let j = i + 1;
1655
+ // 收集当前子组件块:≥4 空格缩进的行(含更深层的嵌套内容)与块内空行,
1656
+ // 直到出现下一个 col 行(2 空格)或顶层内容(serializeRow 会把子组件所有行统一加 4 空格前缀)
1657
+ while (j < lines.length) {
1658
+ const nl = lines[j];
1659
+ if (nl.trim() === '') {
1660
+ // 空行:后续仍是 ≥4 空格内容则归属当前块(多行组件内部空行),否则结束
1661
+ let k = j + 1;
1662
+ while (k < lines.length && lines[k].trim() === '')
1663
+ k++;
1664
+ if (k < lines.length && lines[k].startsWith(' ') && lines[k].trim() !== '') {
1665
+ subLines.push('');
1666
+ j++;
1667
+ continue;
1668
+ }
1669
+ break;
1670
+ }
1671
+ if (nl.startsWith(' ')) {
1672
+ subLines.push(nl.slice(4));
1673
+ j++;
1674
+ continue;
1675
+ }
1676
+ break;
1677
+ }
1678
+ const subResult = this.parse(subLines.join('\n').trim());
1679
+ if (subResult.success && subResult.widget) {
1680
+ currentCol.widget = subResult.widget;
1681
+ i = j - 1;
1682
+ }
1683
+ }
1684
+ }
1685
+ if (widget.cols.length === 0) {
1686
+ return { success: false, error: "row requires at least one col" };
1687
+ }
1688
+ return { success: true, widget };
1689
+ }
1690
+ parseChart(tokens, fullInput, chartType) {
1691
+ const lines = fullInput.split('\n');
1692
+ if (lines.length < 2) {
1693
+ return { success: false, error: "chart requires CSV data" };
1694
+ }
1695
+ const widget = {
1696
+ type: chartType,
1697
+ labels: [],
1698
+ datasets: []
1699
+ };
1700
+ // Check if using old token-based format or new key-value format
1701
+ // Old format has multiple tokens on the first line: "chart-line id title"
1702
+ // New format has only widget type on first line: "chart-line" (key:value params go on their own lines;
1703
+ // inline key:value params like "chart-bar id: x span:24" may remain after when/span extraction and are NOT old format)
1704
+ const firstLineTokens = this.tokenize(lines[0]);
1705
+ // 旧格式 token 是裸值;新格式内联参数均形如 key:value(限定参数白名单,避免带冒号的旧格式标题被误判)
1706
+ const hasKeyValueToken = firstLineTokens.slice(1).some((t) => /^(id|title|height|url|legend|span|when):/.test(t));
1707
+ const isOldFormat = firstLineTokens.length > 1 && !hasKeyValueToken; // Old format: chart-line id title
1708
+ let csvStartIndex = 1;
1709
+ if (isOldFormat) {
1710
+ // Old format: chart-line chart_id "Chart Title"
1711
+ widget.id = firstLineTokens[1];
1712
+ if (firstLineTokens.length > 2) {
1713
+ widget.title = firstLineTokens[2];
1714
+ }
1715
+ // CSV starts at line 1
1716
+ csvStartIndex = 1;
1717
+ }
1718
+ else {
1719
+ // 新格式首行的内联 key:value 参数(如 "chart-bar id: revenue span:24" 提取修饰符后残留),先解析掉
1720
+ // 注意首行可能残留 span:N / when:... 修饰符,不在参数白名单内,忽略即可
1721
+ const applyParam = (key, value) => {
1722
+ switch (key) {
1723
+ case 'id':
1724
+ widget.id = value;
1725
+ break;
1726
+ case 'title':
1727
+ widget.title = value;
1728
+ break;
1729
+ case 'height': {
1730
+ const height = parseInt(value);
1731
+ if (!isNaN(height)) {
1732
+ widget.height = height;
1733
+ }
1734
+ break;
1735
+ }
1736
+ case 'url':
1737
+ widget.url = value;
1738
+ break;
1739
+ case 'legend':
1740
+ widget.legend = value === 'true';
1741
+ break;
1742
+ }
1743
+ };
1744
+ // 配对收集:tokenize 按空格拆分,"id: revenue" 会拆成 "id:" 和 "revenue" 两个 token,
1745
+ // 遇到 key: token 后,将后续裸 token(可含空格的 title/url 等)并入该参数值,直到下一个已知 key
1746
+ let currentKey = null;
1747
+ let currentParts = [];
1748
+ const flushInlineParam = () => {
1749
+ if (currentKey) {
1750
+ applyParam(currentKey, currentParts.join(' '));
1751
+ }
1752
+ currentKey = null;
1753
+ currentParts = [];
1754
+ };
1755
+ for (let t = 1; t < firstLineTokens.length; t++) {
1756
+ const tok = firstLineTokens[t];
1757
+ const cm = tok.match(/^(id|title|height|url|legend|span|when):(.*)$/);
1758
+ if (cm) {
1759
+ flushInlineParam();
1760
+ if (['id', 'title', 'height', 'url', 'legend'].includes(cm[1])) {
1761
+ currentKey = cm[1];
1762
+ if (cm[2]) {
1763
+ currentParts.push(cm[2]); // 形如 key:value 紧凑写法
1764
+ }
1765
+ }
1766
+ // span/when 修饰符已被上游提取(或本就忽略),跳过
1767
+ }
1768
+ else if (currentKey) {
1769
+ currentParts.push(tok);
1770
+ }
1771
+ // 无当前 key 的裸 token:忽略
1772
+ }
1773
+ flushInlineParam();
1774
+ // New format: key-value parameters before CSV
1775
+ for (let i = 1; i < lines.length; i++) {
1776
+ const line = lines[i].trim();
1777
+ if (!line)
1778
+ continue;
1779
+ // Check if this line contains a key:value parameter
1780
+ if (line.includes(':')) {
1781
+ const colonIndex = line.indexOf(':');
1782
+ const key = line.substring(0, colonIndex).trim().toLowerCase();
1783
+ if (['id', 'title', 'height', 'url', 'legend'].includes(key)) {
1784
+ const value = line.substring(colonIndex + 1).trim();
1785
+ applyParam(key, value);
1786
+ csvStartIndex = i + 1;
1787
+ }
1788
+ else {
1789
+ // This line has a colon but isn't a known parameter, treat as CSV data
1790
+ csvStartIndex = i;
1791
+ break;
1792
+ }
1793
+ }
1794
+ else {
1795
+ // This line doesn't contain a colon, start CSV parsing here
1796
+ csvStartIndex = i;
1797
+ break;
1798
+ }
1799
+ }
1800
+ }
1801
+ // Parse CSV data starting from csvStartIndex
1802
+ const csvLines = lines.slice(csvStartIndex).map(l => l.trim()).filter(l => l.length > 0);
1803
+ if (csvLines.length === 0 && !widget.url) {
1804
+ // 无本地 CSV 数据且未设置远端 url 时报错;设置 url 时允许为空(远端拉取)
1805
+ return { success: false, error: "No CSV data provided" };
1806
+ }
1807
+ if (csvLines.length === 0) {
1808
+ // 仅使用远端数据源,跳过本地 CSV 解析
1809
+ return { success: true, widget };
1810
+ }
1811
+ // Parse header row
1812
+ const headerLine = csvLines[0];
1813
+ if (!headerLine) {
1814
+ return { success: false, error: "Empty CSV header" };
1815
+ }
1816
+ const headers = headerLine.split(',').map(h => h.trim());
1817
+ if (headers.length < 2) {
1818
+ return { success: false, error: "CSV must have at least 2 columns" };
1819
+ }
1820
+ // Initialize datasets for each data column (skip first column which is labels)
1821
+ for (let i = 1; i < headers.length; i++) {
1822
+ widget.datasets.push({
1823
+ label: headers[i],
1824
+ data: []
1825
+ });
1826
+ }
1827
+ // Parse data rows
1828
+ for (let i = 1; i < csvLines.length; i++) {
1829
+ const dataLine = csvLines[i].trim();
1830
+ if (!dataLine)
1831
+ continue; // Skip empty lines
1832
+ const values = dataLine.split(',').map(v => v.trim());
1833
+ if (values.length !== headers.length) {
1834
+ return { success: false, error: `Row ${i + 1} has ${values.length} columns, expected ${headers.length}` };
1835
+ }
1836
+ // First column is the label
1837
+ widget.labels.push(values[0]);
1838
+ // Remaining columns are data points
1839
+ for (let j = 1; j < values.length; j++) {
1840
+ const numValue = parseFloat(values[j]);
1841
+ if (isNaN(numValue)) {
1842
+ return { success: false, error: `Invalid number "${values[j]}" in row ${i + 1}, column ${j + 1}` };
1843
+ }
1844
+ widget.datasets[j - 1].data.push(numValue);
1845
+ }
1846
+ }
1847
+ return { success: true, widget };
1848
+ }
1849
+ parseMultipleChoiceQuestion(tokens) {
1850
+ if (tokens.length < 4) {
1851
+ return { success: false, error: "multiple-choice-question requires id, question, and choices array" };
1852
+ }
1853
+ try {
1854
+ const choices = this.parseArray(tokens[3]);
1855
+ const widget = {
1856
+ type: "multiple-choice-question",
1857
+ id: tokens[1],
1858
+ question: tokens[2],
1859
+ choices
1860
+ };
1861
+ if (tokens.length > 4)
1862
+ widget.correctAnswer = tokens[4];
1863
+ // 兼容 showFeedback 位置参数(true/false)与 showFeedback 无值修饰符两种写法
1864
+ const fbToken = tokens.slice(5).find(t => t === 'showFeedback' || t.toLowerCase() === 'true' || t.toLowerCase() === 'false');
1865
+ if (fbToken)
1866
+ widget.showFeedback = fbToken === 'showFeedback' || fbToken.toLowerCase() === 'true';
1867
+ if (tokens.slice(5).includes('disabled'))
1868
+ widget.disabled = true;
1869
+ return { success: true, widget };
1870
+ }
1871
+ catch (error) {
1872
+ return { success: false, error: `Invalid choices array: ${error}` };
1873
+ }
1874
+ }
1875
+ parseShortAnswerQuestion(tokens) {
1876
+ if (tokens.length < 3) {
1877
+ return { success: false, error: "short-answer-question requires id and question" };
1878
+ }
1879
+ const widget = {
1880
+ type: "short-answer-question",
1881
+ id: tokens[1],
1882
+ question: tokens[2]
1883
+ };
1884
+ if (tokens.length > 3)
1885
+ widget.placeholder = tokens[3];
1886
+ if (tokens.length > 4) {
1887
+ try {
1888
+ if (tokens[4].startsWith('[')) {
1889
+ widget.correctAnswers = this.parseArray(tokens[4]);
1890
+ }
1891
+ else {
1892
+ widget.correctAnswer = tokens[4];
1893
+ }
1894
+ }
1895
+ catch (error) {
1896
+ return { success: false, error: `Invalid answers value: ${error}` };
1897
+ }
1898
+ }
1899
+ // 兼容 showFeedback 位置参数(true/false)与 showFeedback 无值修饰符两种写法
1900
+ const fbToken = tokens.slice(5).find(t => t === 'showFeedback' || t.toLowerCase() === 'true' || t.toLowerCase() === 'false');
1901
+ if (fbToken)
1902
+ widget.showFeedback = fbToken === 'showFeedback' || fbToken.toLowerCase() === 'true';
1903
+ if (tokens.slice(5).includes('disabled'))
1904
+ widget.disabled = true;
1905
+ return { success: true, widget };
1906
+ }
1907
+ parseQuiz(tokens, fullInput) {
1908
+ if (tokens.length < 3) {
1909
+ return { success: false, error: "quiz requires id and title" };
1910
+ }
1911
+ const lines = fullInput.split('\n');
1912
+ const firstLine = lines[0];
1913
+ const firstTokens = this.tokenize(firstLine);
1914
+ const widget = {
1915
+ type: "quiz",
1916
+ id: firstTokens[1],
1917
+ title: firstTokens[2],
1918
+ questions: [],
1919
+ showScore: true,
1920
+ showProgress: true
1921
+ };
1922
+ // 首行尾部的 disabled 无值修饰符
1923
+ if (firstTokens.slice(3).includes('disabled'))
1924
+ widget.disabled = true;
1925
+ let i = 1;
1926
+ // Parse configuration lines (key: value format)
1927
+ const quizConfigKeys = new Set(['showscore', 'showprogress', 'passingscore', 'disabled']);
1928
+ while (i < lines.length) {
1929
+ const line = lines[i].trim();
1930
+ if (!line) {
1931
+ i++;
1932
+ continue;
1933
+ }
1934
+ // Check if this is a configuration line (known key: value format)
1935
+ // 避免把含冒号的题目行(如 key:label 选项)误判为配置行
1936
+ let isConfigLine = false;
1937
+ if (line.includes(':')) {
1938
+ const colonIndex = line.indexOf(':');
1939
+ const key = line.substring(0, colonIndex).trim().toLowerCase();
1940
+ isConfigLine = quizConfigKeys.has(key);
1941
+ }
1942
+ if (isConfigLine) {
1943
+ const colonIndex = line.indexOf(':');
1944
+ const key = line.substring(0, colonIndex).trim().toLowerCase();
1945
+ const value = line.substring(colonIndex + 1).trim();
1946
+ switch (key) {
1947
+ case 'showscore':
1948
+ widget.showScore = value.toLowerCase() === 'true';
1949
+ break;
1950
+ case 'showprogress':
1951
+ widget.showProgress = value.toLowerCase() === 'true';
1952
+ break;
1953
+ case 'passingscore':
1954
+ const score = parseInt(value);
1955
+ if (!isNaN(score)) {
1956
+ widget.passingScore = score;
1957
+ }
1958
+ break;
1959
+ case 'disabled':
1960
+ widget.disabled = value.toLowerCase() === 'true';
1961
+ break;
1962
+ default:
1963
+ // Not a known config, might be start of questions
1964
+ break;
1965
+ }
1966
+ i++;
1967
+ }
1968
+ else {
1969
+ // This line doesn't contain a colon, start parsing questions
1970
+ break;
1971
+ }
1972
+ }
1973
+ // Parse questions
1974
+ while (i < lines.length) {
1975
+ const line = lines[i].trim();
1976
+ if (!line) {
1977
+ i++;
1978
+ continue;
1979
+ }
1980
+ const questionTokens = this.tokenize(line);
1981
+ if (questionTokens.length === 0) {
1982
+ i++;
1983
+ continue;
1984
+ }
1985
+ const questionType = questionTokens[0];
1986
+ let question = {};
1987
+ if (questionType === 'mcq') {
1988
+ // mcq questionId "Question text" points ["choice1" "choice2"] "correctAnswer"
1989
+ if (questionTokens.length < 5) {
1990
+ return { success: false, error: `MCQ question on line ${i + 1} requires: id, question, points, and choices` };
1991
+ }
1992
+ const points = parseInt(questionTokens[3]);
1993
+ if (isNaN(points)) {
1994
+ return { success: false, error: `Invalid points value "${questionTokens[3]}" on line ${i + 1}` };
1995
+ }
1996
+ try {
1997
+ const choices = this.parseArray(questionTokens[4]);
1998
+ question = {
1999
+ id: questionTokens[1],
2000
+ type: "mcq",
2001
+ question: questionTokens[2],
2002
+ points: points,
2003
+ choices: choices
2004
+ };
2005
+ if (questionTokens.length > 5) {
2006
+ question.correctAnswer = questionTokens[5];
2007
+ }
2008
+ }
2009
+ catch (error) {
2010
+ return { success: false, error: `Invalid choices array on line ${i + 1}: ${error}` };
2011
+ }
2012
+ }
2013
+ else if (questionType === 'short-answer') {
2014
+ // short-answer questionId "Question text" points "placeholder" ["answer1" "answer2"]
2015
+ if (questionTokens.length < 4) {
2016
+ return { success: false, error: `Short answer question on line ${i + 1} requires: id, question, and points` };
2017
+ }
2018
+ const points = parseInt(questionTokens[3]);
2019
+ if (isNaN(points)) {
2020
+ return { success: false, error: `Invalid points value "${questionTokens[3]}" on line ${i + 1}` };
2021
+ }
2022
+ question = {
2023
+ id: questionTokens[1],
2024
+ type: "short-answer",
2025
+ question: questionTokens[2],
2026
+ points: points
2027
+ };
2028
+ if (questionTokens.length > 4) {
2029
+ question.placeholder = questionTokens[4];
2030
+ }
2031
+ if (questionTokens.length > 5) {
2032
+ try {
2033
+ if (questionTokens[5].startsWith('[')) {
2034
+ question.correctAnswers = this.parseArray(questionTokens[5]);
2035
+ }
2036
+ else {
2037
+ question.correctAnswers = [questionTokens[5]];
2038
+ }
2039
+ }
2040
+ catch (error) {
2041
+ return { success: false, error: `Invalid answers value on line ${i + 1}: ${error}` };
2042
+ }
2043
+ }
2044
+ }
2045
+ else {
2046
+ return { success: false, error: `Unknown question type "${questionType}" on line ${i + 1}` };
2047
+ }
2048
+ widget.questions.push(question);
2049
+ i++;
2050
+ }
2051
+ if (widget.questions.length === 0) {
2052
+ return { success: false, error: "Quiz must contain at least one question" };
2053
+ }
2054
+ return { success: true, widget };
2055
+ }
2056
+ /**
2057
+ * Streaming-aware parsing that returns partial results for incomplete input.
2058
+ * This is designed for AI streaming scenarios where content arrives incrementally.
2059
+ */
2060
+ parseStreaming(input) {
2061
+ const trimmedInput = input.trim();
2062
+ if (!trimmedInput) {
2063
+ return { complete: false, error: "Empty input" };
2064
+ }
2065
+ // Check for unclosed brackets or quotes
2066
+ const pending = this.detectPendingState(trimmedInput);
2067
+ // Try to detect widget type from first token
2068
+ const firstSpaceOrNewline = trimmedInput.search(/[\s\n]/);
2069
+ const firstToken = firstSpaceOrNewline > 0
2070
+ ? trimmedInput.substring(0, firstSpaceOrNewline)
2071
+ : trimmedInput;
2072
+ const detectedType = WIDGET_TYPES.includes(firstToken) ? firstToken : undefined;
2073
+ // If we can't even detect the type yet, return early
2074
+ if (!detectedType) {
2075
+ // Check if it might be a partial type name
2076
+ const partialMatch = WIDGET_TYPES.find(t => t.startsWith(firstToken));
2077
+ if (partialMatch && firstToken.length < partialMatch.length) {
2078
+ return {
2079
+ complete: false,
2080
+ pending: { unclosedQuote: false, unclosedBracket: false }
2081
+ };
2082
+ }
2083
+ return { complete: false, error: `Unknown widget type: ${firstToken}` };
2084
+ }
2085
+ // If there are pending unclosed brackets/quotes, try partial parsing
2086
+ if (pending.unclosedBracket || pending.unclosedQuote) {
2087
+ return this.parsePartialWidget(trimmedInput, detectedType, pending);
2088
+ }
2089
+ // Try full parsing
2090
+ const result = this.parse(trimmedInput);
2091
+ if (result.success && result.widget) {
2092
+ return {
2093
+ complete: true,
2094
+ detectedType,
2095
+ widget: result.widget
2096
+ };
2097
+ }
2098
+ // Full parse failed, try partial parsing
2099
+ return this.parsePartialWidget(trimmedInput, detectedType, pending);
2100
+ }
2101
+ /**
2102
+ * Detect if input has unclosed brackets or quotes
2103
+ */
2104
+ detectPendingState(input) {
2105
+ let inQuotes = false;
2106
+ let quoteChar = '';
2107
+ let bracketDepth = 0;
2108
+ for (let i = 0; i < input.length; i++) {
2109
+ const char = input[i];
2110
+ if ((char === '"' || char === "'") && !inQuotes) {
2111
+ inQuotes = true;
2112
+ quoteChar = char;
2113
+ }
2114
+ else if (char === quoteChar && inQuotes) {
2115
+ inQuotes = false;
2116
+ quoteChar = '';
2117
+ }
2118
+ else if (char === '[' && !inQuotes) {
2119
+ bracketDepth++;
2120
+ }
2121
+ else if (char === ']' && !inQuotes) {
2122
+ bracketDepth--;
2123
+ }
2124
+ }
2125
+ return {
2126
+ unclosedBracket: bracketDepth > 0,
2127
+ unclosedQuote: inQuotes
2128
+ };
2129
+ }
2130
+ /**
2131
+ * Parse widget with incomplete input, filling in defaults for missing parts
2132
+ */
2133
+ parsePartialWidget(input, detectedType, pending) {
2134
+ // For quiz, try to extract what we can
2135
+ if (detectedType === 'quiz') {
2136
+ return this.parsePartialQuiz(input, pending);
2137
+ }
2138
+ // For form, try to extract what we can
2139
+ if (detectedType === 'form') {
2140
+ return this.parsePartialForm(input, pending);
2141
+ }
2142
+ // For charts, try to extract what we can
2143
+ if (detectedType.startsWith('chart-')) {
2144
+ return this.parsePartialChart(input, detectedType, pending);
2145
+ }
2146
+ // For simple widgets, try to complete arrays if needed
2147
+ if (pending.unclosedBracket) {
2148
+ const completedInput = this.autoCompleteArrays(input);
2149
+ const result = this.parse(completedInput);
2150
+ if (result.success && result.widget) {
2151
+ return {
2152
+ complete: false,
2153
+ detectedType,
2154
+ widget: result.widget,
2155
+ pending: { unclosedBracket: true }
2156
+ };
2157
+ }
2158
+ }
2159
+ // Return minimal partial widget
2160
+ const lines = input.split('\n');
2161
+ const tokens = this.tokenize(lines[0]);
2162
+ return {
2163
+ complete: false,
2164
+ detectedType,
2165
+ widget: {
2166
+ type: detectedType,
2167
+ id: tokens[1] || 'pending'
2168
+ },
2169
+ pending
2170
+ };
2171
+ }
2172
+ /**
2173
+ * Auto-complete unclosed arrays by adding closing bracket
2174
+ */
2175
+ autoCompleteArrays(input) {
2176
+ let result = input;
2177
+ let bracketDepth = 0;
2178
+ let inQuotes = false;
2179
+ let quoteChar = '';
2180
+ for (const char of input) {
2181
+ if ((char === '"' || char === "'") && !inQuotes) {
2182
+ inQuotes = true;
2183
+ quoteChar = char;
2184
+ }
2185
+ else if (char === quoteChar && inQuotes) {
2186
+ inQuotes = false;
2187
+ quoteChar = '';
2188
+ }
2189
+ else if (char === '[' && !inQuotes) {
2190
+ bracketDepth++;
2191
+ }
2192
+ else if (char === ']' && !inQuotes) {
2193
+ bracketDepth--;
2194
+ }
2195
+ }
2196
+ // Close unclosed quotes first
2197
+ if (inQuotes) {
2198
+ result += quoteChar;
2199
+ }
2200
+ // Close unclosed brackets
2201
+ while (bracketDepth > 0) {
2202
+ result += ']';
2203
+ bracketDepth--;
2204
+ }
2205
+ return result;
2206
+ }
2207
+ /**
2208
+ * Parse partial quiz, extracting whatever questions are complete
2209
+ */
2210
+ parsePartialQuiz(input, pending) {
2211
+ const lines = input.split('\n');
2212
+ const firstTokens = this.tokenize(lines[0]);
2213
+ const widget = {
2214
+ type: 'quiz',
2215
+ id: firstTokens[1] || 'pending',
2216
+ title: firstTokens[2] || 'Loading...',
2217
+ questions: [],
2218
+ showScore: true,
2219
+ showProgress: true,
2220
+ _streaming: true // Mark as streaming for UI
2221
+ };
2222
+ let i = 1;
2223
+ // Parse config lines
2224
+ while (i < lines.length) {
2225
+ const line = lines[i].trim();
2226
+ if (!line) {
2227
+ i++;
2228
+ continue;
2229
+ }
2230
+ if (line.includes(':')) {
2231
+ const colonIndex = line.indexOf(':');
2232
+ const key = line.substring(0, colonIndex).trim().toLowerCase();
2233
+ const value = line.substring(colonIndex + 1).trim();
2234
+ switch (key) {
2235
+ case 'showscore':
2236
+ widget.showScore = value.toLowerCase() === 'true';
2237
+ break;
2238
+ case 'showprogress':
2239
+ widget.showProgress = value.toLowerCase() === 'true';
2240
+ break;
2241
+ case 'passingscore':
2242
+ const score = parseInt(value);
2243
+ if (!isNaN(score))
2244
+ widget.passingScore = score;
2245
+ break;
2246
+ }
2247
+ i++;
2248
+ }
2249
+ else {
2250
+ break;
2251
+ }
2252
+ }
2253
+ // Parse questions, handling incomplete ones gracefully
2254
+ while (i < lines.length) {
2255
+ const line = lines[i].trim();
2256
+ if (!line) {
2257
+ i++;
2258
+ continue;
2259
+ }
2260
+ const question = this.parsePartialQuestion(line, i);
2261
+ if (question) {
2262
+ widget.questions.push(question);
2263
+ }
2264
+ i++;
2265
+ }
2266
+ return {
2267
+ complete: false,
2268
+ detectedType: 'quiz',
2269
+ widget,
2270
+ pending: {
2271
+ ...pending,
2272
+ awaitingQuestions: widget.questions.length === 0
2273
+ }
2274
+ };
2275
+ }
2276
+ /**
2277
+ * Parse a single question line, handling incomplete input
2278
+ */
2279
+ parsePartialQuestion(line, lineNum) {
2280
+ // Auto-complete the line if needed
2281
+ const completedLine = this.autoCompleteArrays(line);
2282
+ try {
2283
+ const tokens = this.tokenize(completedLine);
2284
+ if (tokens.length === 0)
2285
+ return null;
2286
+ const questionType = tokens[0];
2287
+ if (questionType === 'mcq') {
2288
+ return {
2289
+ id: tokens[1] || `q${lineNum}`,
2290
+ type: 'mcq',
2291
+ question: tokens[2] || 'Loading question...',
2292
+ points: parseInt(tokens[3]) || 0,
2293
+ choices: tokens[4] ? this.parseArray(tokens[4]) : [],
2294
+ correctAnswer: tokens[5],
2295
+ _streaming: tokens.length < 5 // Mark incomplete
2296
+ };
2297
+ }
2298
+ if (questionType === 'short-answer') {
2299
+ const question = {
2300
+ id: tokens[1] || `q${lineNum}`,
2301
+ type: 'short-answer',
2302
+ question: tokens[2] || 'Loading question...',
2303
+ points: parseInt(tokens[3]) || 0,
2304
+ _streaming: tokens.length < 4
2305
+ };
2306
+ if (tokens[4])
2307
+ question.placeholder = tokens[4];
2308
+ if (tokens[5]) {
2309
+ try {
2310
+ question.correctAnswers = tokens[5].startsWith('[')
2311
+ ? this.parseArray(tokens[5])
2312
+ : [tokens[5]];
2313
+ }
2314
+ catch { /* ignore */ }
2315
+ }
2316
+ return question;
2317
+ }
2318
+ }
2319
+ catch {
2320
+ // Return partial question on parse error
2321
+ const partialTokens = line.split(/\s+/);
2322
+ if (partialTokens[0] === 'mcq' || partialTokens[0] === 'short-answer') {
2323
+ return {
2324
+ id: partialTokens[1] || `q${lineNum}`,
2325
+ type: partialTokens[0] === 'mcq' ? 'mcq' : 'short-answer',
2326
+ question: 'Loading...',
2327
+ points: 0,
2328
+ choices: partialTokens[0] === 'mcq' ? [] : undefined,
2329
+ _streaming: true
2330
+ };
2331
+ }
2332
+ }
2333
+ return null;
2334
+ }
2335
+ /**
2336
+ * Parse partial form
2337
+ */
2338
+ parsePartialForm(input, pending) {
2339
+ const lines = input.split('\n');
2340
+ const firstTokens = this.tokenize(lines[0]);
2341
+ const widget = {
2342
+ type: 'form',
2343
+ id: firstTokens[1] || 'pending',
2344
+ submitLabel: firstTokens[2],
2345
+ fields: [],
2346
+ _streaming: true
2347
+ };
2348
+ // 首行参数(inline/columns/cancel/reset/align/layout/labelWidth/contentWidth/disabled)
2349
+ for (let ti = 3; ti < firstTokens.length; ti++) {
2350
+ const t = firstTokens[ti];
2351
+ if (t === 'inline' || t.startsWith('inline:')) {
2352
+ widget.inline = t === 'inline' ? true : t.split(':')[1]?.toLowerCase() === 'true';
2353
+ }
2354
+ else if (t.startsWith('columns:')) {
2355
+ widget.columns = parseInt(t.split(':')[1]);
2356
+ }
2357
+ else if (t.startsWith('cancel:')) {
2358
+ widget.cancelLabel = t.slice('cancel:'.length).replace(/^["']|["']$/g, '');
2359
+ }
2360
+ else if (t.startsWith('reset:')) {
2361
+ widget.resetLabel = t.slice('reset:'.length).replace(/^["']|["']$/g, '');
2362
+ }
2363
+ else if (t.startsWith('align:')) {
2364
+ const a = t.split(':')[1]?.toLowerCase();
2365
+ if (a === 'left' || a === 'center' || a === 'right')
2366
+ widget.buttonsAlign = a;
2367
+ }
2368
+ else if (t.startsWith('layout:')) {
2369
+ const l = t.split(':')[1]?.toLowerCase();
2370
+ if (l === 'horizontal' || l === 'vertical')
2371
+ widget.layout = l;
2372
+ }
2373
+ else if (t.startsWith('labelWidth:')) {
2374
+ const n = parseFloat(t.split(':')[1]);
2375
+ if (!isNaN(n) && n > 0)
2376
+ widget.labelWidth = n;
2377
+ }
2378
+ else if (t.startsWith('contentWidth:')) {
2379
+ const n = parseFloat(t.split(':')[1]);
2380
+ if (!isNaN(n) && n > 0)
2381
+ widget.contentWidth = n;
2382
+ }
2383
+ else if (t === 'disabled') {
2384
+ widget.disabled = true;
2385
+ }
2386
+ }
2387
+ // 解析后续行
2388
+ let i = 1;
2389
+ let currentRow = null;
2390
+ let currentRowGutter = undefined;
2391
+ let hasRows = false;
2392
+ for (; i < lines.length; i++) {
2393
+ const line = lines[i];
2394
+ if (!line.trim())
2395
+ continue;
2396
+ // 配置行
2397
+ if (line.startsWith(' ') && !line.startsWith(' ')) {
2398
+ const trimmed = line.trim();
2399
+ if (trimmed.includes(':') && !hasRows) {
2400
+ const colonIdx = trimmed.indexOf(':');
2401
+ const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
2402
+ const val = trimmed.slice(colonIdx + 1).trim();
2403
+ if (key === 'inline') {
2404
+ widget.inline = val.toLowerCase() === 'true';
2405
+ continue;
2406
+ }
2407
+ if (key === 'columns') {
2408
+ widget.columns = parseInt(val);
2409
+ continue;
2410
+ }
2411
+ if (key === 'cancel') {
2412
+ widget.cancelLabel = val.replace(/^["']|["']$/g, '');
2413
+ continue;
2414
+ }
2415
+ if (key === 'reset') {
2416
+ widget.resetLabel = val.replace(/^["']|["']$/g, '');
2417
+ continue;
2418
+ }
2419
+ if (key === 'align') {
2420
+ const a = val.toLowerCase();
2421
+ if (a === 'left' || a === 'center' || a === 'right')
2422
+ widget.buttonsAlign = a;
2423
+ continue;
2424
+ }
2425
+ if (key === 'layout') {
2426
+ const l = val.toLowerCase();
2427
+ if (l === 'horizontal' || l === 'vertical')
2428
+ widget.layout = l;
2429
+ continue;
2430
+ }
2431
+ if (key === 'labelwidth') {
2432
+ const n = parseFloat(val);
2433
+ if (!isNaN(n) && n > 0)
2434
+ widget.labelWidth = n;
2435
+ continue;
2436
+ }
2437
+ if (key === 'contentwidth') {
2438
+ const n = parseFloat(val);
2439
+ if (!isNaN(n) && n > 0)
2440
+ widget.contentWidth = n;
2441
+ continue;
2442
+ }
2443
+ if (key === 'disabled') {
2444
+ widget.disabled = val.toLowerCase() === 'true';
2445
+ continue;
2446
+ }
2447
+ }
2448
+ // row 关键字(可选 gutter:N)
2449
+ if (trimmed === 'row' || trimmed.startsWith('row ') || trimmed.startsWith('row\t')) {
2450
+ if (currentRow && currentRow.length > 0) {
2451
+ widget.rows = widget.rows || [];
2452
+ widget.rows.push({ ...(currentRowGutter !== undefined ? { gutter: currentRowGutter } : {}), fields: currentRow });
2453
+ }
2454
+ currentRow = [];
2455
+ currentRowGutter = this.parseRowGutter(trimmed);
2456
+ hasRows = true;
2457
+ continue;
2458
+ }
2459
+ // 普通 field(支持尾部 when:field:values / span:N 修饰符,任意顺序)
2460
+ if (!hasRows) {
2461
+ let fieldLine = this.autoCompleteArrays(line.slice(2));
2462
+ let fieldSpan;
2463
+ let fieldWhen;
2464
+ [fieldLine, fieldSpan, fieldWhen] = this.extractFieldModifiers(fieldLine);
2465
+ try {
2466
+ const fieldResult = this.parse(fieldLine);
2467
+ if (fieldResult.success && fieldResult.widget) {
2468
+ if (fieldSpan !== undefined) {
2469
+ fieldResult.widget.span = fieldSpan;
2470
+ }
2471
+ if (fieldWhen !== undefined) {
2472
+ fieldResult.widget.when = fieldWhen;
2473
+ }
2474
+ widget.fields.push(fieldResult.widget);
2475
+ }
2476
+ }
2477
+ catch { /* skip */ }
2478
+ }
2479
+ }
2480
+ // row 内的字段(支持尾部 when:field:values / span:N,任意顺序)
2481
+ if (line.startsWith(' ') && currentRow !== null) {
2482
+ let fieldLine = this.autoCompleteArrays(line.slice(4).trim());
2483
+ let fieldSpan;
2484
+ let fieldWhen;
2485
+ [fieldLine, fieldSpan, fieldWhen] = this.extractFieldModifiers(fieldLine);
2486
+ try {
2487
+ const fieldResult = this.parse(fieldLine);
2488
+ if (fieldResult.success && fieldResult.widget) {
2489
+ if (fieldSpan !== undefined) {
2490
+ fieldResult.widget.span = fieldSpan;
2491
+ }
2492
+ if (fieldWhen !== undefined) {
2493
+ fieldResult.widget.when = fieldWhen;
2494
+ }
2495
+ currentRow.push(fieldResult.widget);
2496
+ }
2497
+ }
2498
+ catch { /* skip */ }
2499
+ }
2500
+ }
2501
+ if (currentRow && currentRow.length > 0) {
2502
+ widget.rows = widget.rows || [];
2503
+ widget.rows.push({ ...(currentRowGutter !== undefined ? { gutter: currentRowGutter } : {}), fields: currentRow });
2504
+ }
2505
+ if (widget.rows && widget.rows.length > 0) {
2506
+ widget.fields = widget.rows.map((r) => Array.isArray(r) ? r : (r.fields || [])).flat();
2507
+ }
2508
+ return {
2509
+ complete: false,
2510
+ detectedType: 'form',
2511
+ widget,
2512
+ pending: { ...pending, awaitingFields: true }
2513
+ };
2514
+ }
2515
+ /**
2516
+ * Parse partial chart
2517
+ */
2518
+ parsePartialChart(input, detectedType, pending) {
2519
+ const lines = input.split('\n');
2520
+ const firstTokens = this.tokenize(lines[0]);
2521
+ const widget = {
2522
+ type: detectedType,
2523
+ id: firstTokens[1] || 'pending',
2524
+ title: firstTokens[2],
2525
+ labels: [],
2526
+ datasets: [],
2527
+ _streaming: true
2528
+ };
2529
+ // Try to parse any complete CSV rows
2530
+ let csvStarted = false;
2531
+ for (let i = 1; i < lines.length; i++) {
2532
+ const line = lines[i].trim();
2533
+ if (!line)
2534
+ continue;
2535
+ if (line.includes(':') && !csvStarted) {
2536
+ // Config line
2537
+ const [key, value] = line.split(':').map(s => s.trim());
2538
+ if (key.toLowerCase() === 'title')
2539
+ widget.title = value;
2540
+ if (key.toLowerCase() === 'id')
2541
+ widget.id = value;
2542
+ }
2543
+ else if (line.includes(',')) {
2544
+ csvStarted = true;
2545
+ // CSV data - just mark that we have data coming
2546
+ }
2547
+ }
2548
+ return {
2549
+ complete: false,
2550
+ detectedType,
2551
+ widget,
2552
+ pending: {
2553
+ ...pending,
2554
+ awaitingData: true
2555
+ }
2556
+ };
2557
+ }
2558
+ }
2559
+ export function parseDSL(input) {
2560
+ const parser = new DSLParser();
2561
+ return parser.parse(input);
2562
+ }
2563
+ export function parseDSLStreaming(input) {
2564
+ const parser = new DSLParser();
2565
+ return parser.parseStreaming(input);
2566
+ }