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

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,7 @@
1
+ /** 把 widget 对象转回 DSL 字符串 */
2
+ export declare function serializeDSL(widget: any): string;
3
+ /** 序列化单个字段:带 span/when 时附加尾部修饰符(类似 antd Col)。
4
+ * 多行字段(如 chart)时修饰符追加到首行尾部,保证 when/span 仍能被行尾正则提取。
5
+ * 多条件(when 数组)输出多个 when 修饰符(AND 语义);单条件保持单个 when。
6
+ * 分组形态(FieldWhenGroup)输出 when:(all 且组)与 when-any:(any 或组)修饰符 */
7
+ export declare function serializeFieldWithSpan(f: any): string;
@@ -0,0 +1,633 @@
1
+ /** 把 widget 对象转回 DSL 字符串 */
2
+ export function serializeDSL(widget) {
3
+ if (!widget || !widget.type)
4
+ return '';
5
+ switch (widget.type) {
6
+ case 'text':
7
+ return serializeText(widget);
8
+ case 'text-input':
9
+ return serializeTextInput(widget);
10
+ case 'button-group':
11
+ return serializeButtonGroup(widget);
12
+ case 'button':
13
+ return serializeButton(widget);
14
+ case 'select':
15
+ return serializeSelect(widget);
16
+ case 'select-multi':
17
+ return serializeSelectMulti(widget);
18
+ case 'radio-group':
19
+ return serializeRadioGroup(widget);
20
+ case 'cascade':
21
+ return serializeCascade(widget);
22
+ case 'tree-select':
23
+ return serializeTreeSelect(widget);
24
+ case 'slider':
25
+ return serializeSlider(widget);
26
+ case 'switch':
27
+ return serializeSwitch(widget);
28
+ case 'date-picker':
29
+ return serializeDatePicker(widget);
30
+ case 'number-input':
31
+ return serializeNumberInput(widget);
32
+ case 'form':
33
+ return serializeForm(widget);
34
+ case 'row':
35
+ return serializeRow(widget);
36
+ case 'chart-line':
37
+ case 'chart-bar':
38
+ case 'chart-pie':
39
+ case 'chart-scatter':
40
+ return serializeChart(widget);
41
+ case 'multiple-choice-question':
42
+ return serializeMCQ(widget);
43
+ case 'short-answer-question':
44
+ return serializeSAQ(widget);
45
+ case 'quiz':
46
+ return serializeQuiz(widget);
47
+ case 'incomplete':
48
+ return serializeIncomplete(widget);
49
+ default:
50
+ return '';
51
+ }
52
+ }
53
+ function quote(s) {
54
+ if (!s)
55
+ return '""';
56
+ if (s.includes(' ') || s.includes('"') || s.includes("'") || s.includes('\n')) {
57
+ return `"${s.replace(/"/g, '\\"')}"`;
58
+ }
59
+ return s;
60
+ }
61
+ /** 对象树 JSON 串原样回填:单行紧凑输出(无外层引号、不 escape,
62
+ * 经 tokenize 的 scanDataArray 引号感知扫描后可无损往返为单 token) */
63
+ function jsonArg(v) {
64
+ return JSON.stringify(v);
65
+ }
66
+ function arr(items) {
67
+ if (!items || items.length === 0)
68
+ return '[]';
69
+ return `[${items.map(i => quote(String(i))).join(' ')}]`;
70
+ }
71
+ function serializeText(w) {
72
+ const parts = ['text', w.id || 'id', quote(w.text || '')];
73
+ if (w.size)
74
+ parts.push(`size:${w.size}`);
75
+ if (w.color)
76
+ parts.push(`color:${w.color}`);
77
+ if (w.bold)
78
+ parts.push('bold');
79
+ if (w.align)
80
+ parts.push(`align:${w.align}`);
81
+ if (w.alert) {
82
+ parts.push('alert');
83
+ // 默认 info 不序列化(省略),warning/error 显式写出
84
+ if (w.alertStyle && w.alertStyle !== 'info')
85
+ parts.push(`alertStyle:${w.alertStyle}`);
86
+ }
87
+ return parts.join(' ');
88
+ }
89
+ function serializeTextInput(w) {
90
+ const parts = ['text-input', w.id || 'id'];
91
+ // label/placeholder/default 是位置参数:后位存在时前位必须占位(空串 ""),避免错位
92
+ const hasPlaceholder = !!w.placeholder;
93
+ const hasDefault = w.default !== undefined && w.default !== null && w.default !== '';
94
+ if (w.label || hasPlaceholder || hasDefault)
95
+ parts.push(w.label ? quote(w.label) : '""');
96
+ if (hasPlaceholder || hasDefault)
97
+ parts.push(w.placeholder ? quote(w.placeholder) : '""');
98
+ if (hasDefault)
99
+ parts.push(quote(w.default));
100
+ if (w.hint)
101
+ parts.push(`hint:${quote(w.hint)}`);
102
+ if (w.required)
103
+ parts.push('required');
104
+ if (w.disabled)
105
+ parts.push('disabled');
106
+ if (w.pattern)
107
+ parts.push(`pattern:${quote(w.pattern)}`);
108
+ if (w.message)
109
+ parts.push(`message:${quote(w.message)}`);
110
+ return parts.join(' ');
111
+ }
112
+ function serializeButtonGroup(w) {
113
+ const parts = ['button-group', w.id || 'id', arr(w.choices)];
114
+ if (w.default)
115
+ parts.push(quote(w.default));
116
+ if (w.label)
117
+ parts.push(`label:${quote(w.label)}`);
118
+ if (w.hint)
119
+ parts.push(`hint:${quote(w.hint)}`);
120
+ if (w.required)
121
+ parts.push('required');
122
+ if (w.disabled)
123
+ parts.push('disabled');
124
+ return parts.join(' ');
125
+ }
126
+ function serializeButton(w) {
127
+ const parts = ['button', w.id || 'id'];
128
+ if (w.label)
129
+ parts.push(quote(w.label));
130
+ if (w.color)
131
+ parts.push(`color:${w.color}`);
132
+ if (w.textColor)
133
+ parts.push(`textColor:${w.textColor}`);
134
+ if (w.size)
135
+ parts.push(`size:${w.size}`);
136
+ if (w.disabled)
137
+ parts.push('disabled');
138
+ return parts.join(' ');
139
+ }
140
+ function serializeSelect(w) {
141
+ const parts = ['select', w.id || 'id', arr(w.choices)];
142
+ if (w.default)
143
+ parts.push(quote(w.default));
144
+ if (w.label)
145
+ parts.push(`label:${quote(w.label)}`);
146
+ if (w.hint)
147
+ parts.push(`hint:${quote(w.hint)}`);
148
+ if (w.required)
149
+ parts.push('required');
150
+ if (w.disabled)
151
+ parts.push('disabled');
152
+ // 空串 url 也要写回(url:"" 表示远端模式但路径待填),否则 round-trip 后远端状态丢失
153
+ if (w.url !== undefined && w.url !== null)
154
+ parts.push(`url:${quote(w.url)}`);
155
+ if (w.searchable)
156
+ parts.push('searchable');
157
+ return parts.join(' ');
158
+ }
159
+ function serializeSelectMulti(w) {
160
+ // choices 为对象数组([{value|key,label} ...])时输出单行紧凑 JSON:
161
+ // tokenize 的数组模式引号/嵌套感知可整体接收,parser 的 parseObjectChoices 特判识别,保证 round-trip
162
+ const isObjectChoices = Array.isArray(w.choices) && w.choices.length > 0
163
+ && w.choices.every((c) => c && typeof c === 'object');
164
+ const parts = ['select-multi', w.id || 'id', isObjectChoices ? jsonArg(w.choices) : arr(w.choices)];
165
+ if (w.default !== undefined && w.default !== null) {
166
+ const d = Array.isArray(w.default) ? w.default : [w.default];
167
+ parts.push(arr(d));
168
+ }
169
+ if (w.label)
170
+ parts.push(`label:${quote(w.label)}`);
171
+ if (w.hint)
172
+ parts.push(`hint:${quote(w.hint)}`);
173
+ // 选项布局:columns 自定义列数优先;direction 仅写回 horizontal(vertical 为默认,省略保持 DSL 简洁)
174
+ if (w.columns)
175
+ parts.push(`columns:${w.columns}`);
176
+ else if (w.direction === 'horizontal')
177
+ parts.push('direction:horizontal');
178
+ if (w.required)
179
+ parts.push('required');
180
+ if (w.disabled)
181
+ parts.push('disabled');
182
+ if (w.message)
183
+ parts.push(`message:${quote(w.message)}`);
184
+ // 空串 url 也要写回(url:"" 表示远端模式但路径待填),否则 round-trip 后远端状态丢失
185
+ if (w.url !== undefined && w.url !== null)
186
+ parts.push(`url:${quote(w.url)}`);
187
+ return parts.join(' ');
188
+ }
189
+ function serializeRadioGroup(w) {
190
+ // choices 为对象数组时输出单行紧凑 JSON(与 select-multi 一致,保证 round-trip)
191
+ const isObjectChoices = Array.isArray(w.choices) && w.choices.length > 0
192
+ && w.choices.every((c) => c && typeof c === 'object');
193
+ const parts = ['radio-group', w.id || 'id', isObjectChoices ? jsonArg(w.choices) : arr(w.choices)];
194
+ if (w.default !== undefined && w.default !== null)
195
+ parts.push(quote(w.default));
196
+ if (w.label)
197
+ parts.push(`label:${quote(w.label)}`);
198
+ if (w.hint)
199
+ parts.push(`hint:${quote(w.hint)}`);
200
+ // 按钮样式标记(antd optionType:button)
201
+ if (w.button)
202
+ parts.push('button');
203
+ // 选项布局:columns 自定义列数优先;direction 仅写回 horizontal(vertical 为默认,省略)
204
+ if (w.columns)
205
+ parts.push(`columns:${w.columns}`);
206
+ else if (w.direction === 'horizontal')
207
+ parts.push('direction:horizontal');
208
+ if (w.required)
209
+ parts.push('required');
210
+ if (w.disabled)
211
+ parts.push('disabled');
212
+ if (w.message)
213
+ parts.push(`message:${quote(w.message)}`);
214
+ // 空串 url 也要写回(url:"" 表示远端模式但路径待填),否则 round-trip 后远端状态丢失
215
+ if (w.url !== undefined && w.url !== null)
216
+ parts.push(`url:${quote(w.url)}`);
217
+ return parts.join(' ');
218
+ }
219
+ function serializeCascade(w) {
220
+ const parts = ['cascade', w.id || 'id', arr(w.choices)];
221
+ if (w.default)
222
+ parts.push(quote(w.default));
223
+ // 嵌套对象树数据源(单行 JSON 串,可往返解析);空数组也写回(保住编辑器"数据树"模式)
224
+ if (w.data !== undefined && w.data !== null)
225
+ parts.push(`data:${jsonArg(w.data)}`);
226
+ // 空串 url 也要写回(url:"" 表示远端模式但路径待填),否则 round-trip 后远端状态丢失
227
+ if (w.url !== undefined && w.url !== null)
228
+ parts.push(`url:${quote(w.url)}`);
229
+ // 懒加载模式(url 存在时生效):首次只拉根级,点击父级后 url?parent=<value> 逐级请求子级
230
+ if (w.lazy)
231
+ parts.push('lazy');
232
+ if (w.label)
233
+ parts.push(`label:${quote(w.label)}`);
234
+ if (w.hint)
235
+ parts.push(`hint:${quote(w.hint)}`);
236
+ if (w.required)
237
+ parts.push('required');
238
+ if (w.disabled)
239
+ parts.push('disabled');
240
+ if (w.placeholder)
241
+ parts.push(`placeholder:${quote(w.placeholder)}`);
242
+ if (w.message)
243
+ parts.push(`message:${quote(w.message)}`);
244
+ return parts.join(' ');
245
+ }
246
+ function serializeTreeSelect(w) {
247
+ const parts = ['tree-select', w.id || 'id', arr(w.choices)];
248
+ // default:多选为数组,单选为单值(位置参数)
249
+ if (w.default !== undefined && w.default !== null && (!Array.isArray(w.default) || w.default.length > 0)) {
250
+ parts.push(Array.isArray(w.default) ? arr(w.default) : quote(w.default));
251
+ }
252
+ // 嵌套对象树数据源(单行 JSON 串,可往返解析);空数组也写回(保住编辑器"数据树"模式)
253
+ if (w.data !== undefined && w.data !== null)
254
+ parts.push(`data:${jsonArg(w.data)}`);
255
+ // 空串 url 也要写回(url:"" 表示远端模式但路径待填)
256
+ if (w.url !== undefined && w.url !== null)
257
+ parts.push(`url:${quote(w.url)}`);
258
+ if (w.lazy)
259
+ parts.push('lazy');
260
+ if (w.multiple)
261
+ parts.push('multiple');
262
+ if (w.checkStrictly)
263
+ parts.push('checkStrictly');
264
+ if (w.showCheckedStrategy)
265
+ parts.push(`strategy:${w.showCheckedStrategy}`);
266
+ if (w.maxTagCount !== undefined && w.maxTagCount !== null)
267
+ parts.push(`maxTagCount:${w.maxTagCount}`);
268
+ if (w.treeLine)
269
+ parts.push('treeLine');
270
+ if (w.searchable)
271
+ parts.push('searchable');
272
+ if (w.label)
273
+ parts.push(`label:${quote(w.label)}`);
274
+ if (w.hint)
275
+ parts.push(`hint:${quote(w.hint)}`);
276
+ if (w.required)
277
+ parts.push('required');
278
+ if (w.disabled)
279
+ parts.push('disabled');
280
+ if (w.placeholder)
281
+ parts.push(`placeholder:${quote(w.placeholder)}`);
282
+ if (w.message)
283
+ parts.push(`message:${quote(w.message)}`);
284
+ return parts.join(' ');
285
+ }
286
+ function serializeSlider(w) {
287
+ const parts = ['slider', w.id || 'id', String(w.min ?? 0), String(w.max ?? 100), String(w.step ?? 1)];
288
+ if (w.default !== undefined)
289
+ parts.push(String(w.default));
290
+ if (w.label)
291
+ parts.push(`label:${quote(w.label)}`);
292
+ if (w.hint)
293
+ parts.push(`hint:${quote(w.hint)}`);
294
+ if (w.required)
295
+ parts.push('required');
296
+ if (w.disabled)
297
+ parts.push('disabled');
298
+ return parts.join(' ');
299
+ }
300
+ function serializeSwitch(w) {
301
+ const parts = ['switch', w.id || 'id'];
302
+ if (w.default !== undefined)
303
+ parts.push(w.default ? 'true' : 'false');
304
+ if (w.label)
305
+ parts.push(`label:${quote(w.label)}`);
306
+ if (w.checkedText)
307
+ parts.push(`checkedText:${quote(w.checkedText)}`);
308
+ if (w.unCheckedText)
309
+ parts.push(`unCheckedText:${quote(w.unCheckedText)}`);
310
+ if (w.hint)
311
+ parts.push(`hint:${quote(w.hint)}`);
312
+ if (w.required)
313
+ parts.push('required');
314
+ if (w.disabled)
315
+ parts.push('disabled');
316
+ return parts.join(' ');
317
+ }
318
+ function serializeDatePicker(w) {
319
+ const parts = ['date-picker', w.id || 'id'];
320
+ // label 作为位置参数(与 parser 约定一致:date-picker id "标签" ...)
321
+ if (w.label)
322
+ parts.push(quote(w.label));
323
+ // default:时间戳(数字)或格式化字符串;range 模式为 [start end] 数组
324
+ if (w.default !== undefined && w.default !== null && (!Array.isArray(w.default) || w.default.length > 0)) {
325
+ if (Array.isArray(w.default)) {
326
+ parts.push(arr(w.default));
327
+ }
328
+ else if (typeof w.default === 'number') {
329
+ parts.push(String(w.default));
330
+ }
331
+ else {
332
+ parts.push(quote(w.default));
333
+ }
334
+ }
335
+ if (w.picker && w.picker !== 'date')
336
+ parts.push(`picker:${w.picker}`);
337
+ if (w.range)
338
+ parts.push('range');
339
+ if (w.format)
340
+ parts.push(`format:${quote(w.format)}`);
341
+ if (w.disableBeforeNow)
342
+ parts.push('disableBeforeNow');
343
+ if (w.disableAfterNow)
344
+ parts.push('disableAfterNow');
345
+ if (w.placeholder)
346
+ parts.push(`placeholder:${quote(w.placeholder)}`);
347
+ if (w.hint)
348
+ parts.push(`hint:${quote(w.hint)}`);
349
+ if (w.required)
350
+ parts.push('required');
351
+ if (w.disabled)
352
+ parts.push('disabled');
353
+ if (w.message)
354
+ parts.push(`message:${quote(w.message)}`);
355
+ return parts.join(' ');
356
+ }
357
+ function serializeNumberInput(w) {
358
+ const parts = ['number-input', w.id || 'id'];
359
+ // label/default 是位置参数:default 存在时 label 必须占位(与 parser 约定一致)
360
+ const hasDefault = w.default !== undefined && w.default !== null;
361
+ if (w.label || hasDefault)
362
+ parts.push(w.label ? quote(w.label) : '""');
363
+ if (hasDefault)
364
+ parts.push(String(w.default));
365
+ if (w.min !== undefined)
366
+ parts.push(`min:${w.min}`);
367
+ if (w.max !== undefined)
368
+ parts.push(`max:${w.max}`);
369
+ if (w.step !== undefined)
370
+ parts.push(`step:${w.step}`);
371
+ if (w.precision !== undefined)
372
+ parts.push(`precision:${w.precision}`);
373
+ if (w.placeholder)
374
+ parts.push(`placeholder:${quote(w.placeholder)}`);
375
+ if (w.hint)
376
+ parts.push(`hint:${quote(w.hint)}`);
377
+ if (w.required)
378
+ parts.push('required');
379
+ if (w.disabled)
380
+ parts.push('disabled');
381
+ if (w.message)
382
+ parts.push(`message:${quote(w.message)}`);
383
+ return parts.join(' ');
384
+ }
385
+ /** when 条件值段序列化:op 为数值/时间戳比较时加运算符前缀(when:field:>18),eq 无前缀 */
386
+ function whenOpPrefix(op) {
387
+ if (op === 'gt')
388
+ return '>';
389
+ if (op === 'gte')
390
+ return '>=';
391
+ if (op === 'lt')
392
+ return '<';
393
+ if (op === 'lte')
394
+ return '<=';
395
+ if (op === 'ne')
396
+ return '!=';
397
+ return '';
398
+ }
399
+ /** 序列化单个字段:带 span/when 时附加尾部修饰符(类似 antd Col)。
400
+ * 多行字段(如 chart)时修饰符追加到首行尾部,保证 when/span 仍能被行尾正则提取。
401
+ * 多条件(when 数组)输出多个 when 修饰符(AND 语义);单条件保持单个 when。
402
+ * 分组形态(FieldWhenGroup)输出 when:(all 且组)与 when-any:(any 或组)修饰符 */
403
+ export function serializeFieldWithSpan(f) {
404
+ const base = serializeDSL(f);
405
+ if (!base)
406
+ return '';
407
+ let suffix = '';
408
+ if (f.span)
409
+ suffix += ` span:${f.span}`;
410
+ const when = f.when;
411
+ if (when && typeof when === 'object' && !Array.isArray(when) && ('all' in when || 'any' in when)) {
412
+ // 分组形态:且组(when:)在前,或组(when-any:)在后
413
+ for (const w of when.all || []) {
414
+ if (w?.field && Array.isArray(w.values) && w.values.length > 0) {
415
+ suffix += ` when:${w.field}:${whenOpPrefix(w.op)}${w.values.join(',')}`;
416
+ }
417
+ }
418
+ for (const w of when.any || []) {
419
+ if (w?.field && Array.isArray(w.values) && w.values.length > 0) {
420
+ suffix += ` when-any:${w.field}:${whenOpPrefix(w.op)}${w.values.join(',')}`;
421
+ }
422
+ }
423
+ }
424
+ else {
425
+ const whenList = Array.isArray(when) ? when : (when ? [when] : []);
426
+ for (const w of whenList) {
427
+ if (w?.field && Array.isArray(w.values) && w.values.length > 0) {
428
+ suffix += ` when:${w.field}:${whenOpPrefix(w.op)}${w.values.join(',')}`;
429
+ }
430
+ }
431
+ }
432
+ if (!suffix)
433
+ return base;
434
+ const baseLines = base.split('\n');
435
+ baseLines[0] = baseLines[0] + suffix;
436
+ return baseLines.join('\n');
437
+ }
438
+ function serializeForm(w) {
439
+ const lines = [];
440
+ const first = ['form', w.id || 'id'];
441
+ if (w.submitLabel)
442
+ first.push(quote(w.submitLabel));
443
+ if (w.inline)
444
+ first.push('inline');
445
+ if (w.columns)
446
+ first.push(`columns:${w.columns}`);
447
+ if (w.cancelLabel)
448
+ first.push(`cancel:${w.cancelLabel.includes(' ') ? quote(w.cancelLabel) : w.cancelLabel}`);
449
+ if (w.resetLabel)
450
+ first.push(`reset:${w.resetLabel.includes(' ') ? quote(w.resetLabel) : w.resetLabel}`);
451
+ if (w.buttonsAlign)
452
+ first.push(`align:${w.buttonsAlign}`);
453
+ if (w.layout)
454
+ first.push(`layout:${w.layout}`);
455
+ if (w.labelWidth)
456
+ first.push(`labelWidth:${w.labelWidth}`);
457
+ if (w.contentWidth)
458
+ first.push(`contentWidth:${w.contentWidth}`);
459
+ if (w.disabled)
460
+ first.push('disabled');
461
+ lines.push(first.join(' '));
462
+ /** 字段 DSL 逐行加缩进:单行字段用浅缩进;多行字段(如 chart)首行用浅缩进、
463
+ * 其余行用深缩进(多 2 空格),保证 round-trip 时块内行仍归属该字段 */
464
+ const pushField = (fieldDsl, indent) => {
465
+ if (!fieldDsl)
466
+ return;
467
+ const ls = fieldDsl.split('\n');
468
+ for (let k = 0; k < ls.length; k++) {
469
+ const l = ls[k];
470
+ if (!l) {
471
+ lines.push(l);
472
+ continue;
473
+ }
474
+ lines.push(k === 0 ? indent + l : indent + ' ' + l);
475
+ }
476
+ };
477
+ if (w.rows && w.rows.length > 0) {
478
+ for (const row of w.rows) {
479
+ // 兼容旧数组形态的 row(纯字段数组)与新对象形态({ gutter, fields })
480
+ const fields = Array.isArray(row) ? row : (row.fields || []);
481
+ const gutter = Array.isArray(row) ? undefined : row.gutter;
482
+ lines.push(gutter !== undefined && gutter !== null ? ` row gutter:${gutter}` : ' row');
483
+ for (const f of fields) {
484
+ pushField(serializeFieldWithSpan(f), ' ');
485
+ }
486
+ }
487
+ }
488
+ else if (w.fields && w.fields.length > 0) {
489
+ for (const f of w.fields) {
490
+ pushField(serializeFieldWithSpan(f), ' ');
491
+ }
492
+ }
493
+ return lines.join('\n');
494
+ }
495
+ /** 序列化独立栅格组件 row */
496
+ function serializeRow(w) {
497
+ const lines = [];
498
+ const first = ['row'];
499
+ if (w.id)
500
+ first.push(w.id);
501
+ if (w.gutter !== undefined && w.gutter !== null)
502
+ first.push(`gutter:${w.gutter}`);
503
+ if (w.justify)
504
+ first.push(`justify:${w.justify}`);
505
+ if (w.align)
506
+ first.push(`align:${w.align}`);
507
+ lines.push(first.join(' '));
508
+ for (const c of (w.cols || [])) {
509
+ const parts = ['col'];
510
+ if (c.span)
511
+ parts.push(`span:${c.span}`);
512
+ if (c.offset)
513
+ parts.push(`offset:${c.offset}`);
514
+ if (c.align)
515
+ parts.push(`align:${c.align}`);
516
+ lines.push(' ' + parts.join(' '));
517
+ if (c.widget) {
518
+ const sub = serializeDSL(c.widget);
519
+ if (sub) {
520
+ for (const l of sub.split('\n')) {
521
+ lines.push(' ' + l);
522
+ }
523
+ }
524
+ }
525
+ }
526
+ return lines.join('\n');
527
+ }
528
+ function serializeChart(w) {
529
+ const lines = [w.type];
530
+ if (w.id)
531
+ lines.push(`id: ${w.id}`);
532
+ if (w.title)
533
+ lines.push(`title: ${w.title}`);
534
+ if (w.height)
535
+ lines.push(`height: ${w.height}`);
536
+ // url 为空串也回写(编辑器"远端数据源"二态切换依赖空串占位)
537
+ if (w.url !== undefined && w.url !== null)
538
+ lines.push(`url: ${w.url}`);
539
+ if (w.legend !== undefined && w.legend !== null)
540
+ lines.push(`legend: ${w.legend ? 'true' : 'false'}`);
541
+ const ds = w.datasets || [];
542
+ const labels = w.labels || [];
543
+ // 有 url 且无本地数据时不输出空 CSV(数据由远端拉取)
544
+ if (w.url && labels.length === 0) {
545
+ return lines.join('\n');
546
+ }
547
+ // CSV header
548
+ const headerCols = [];
549
+ // 尝试推断第一列名称:如果 labels 都是数字就用 X,否则用 Category
550
+ const allNumeric = labels.length > 0 && labels.every((l) => !isNaN(parseFloat(l)));
551
+ headerCols.push(allNumeric ? 'X' : 'Category');
552
+ for (const d of ds)
553
+ headerCols.push(d.label || 'Series');
554
+ lines.push(headerCols.join(','));
555
+ // CSV data rows
556
+ const rowCount = labels.length;
557
+ for (let i = 0; i < rowCount; i++) {
558
+ const row = [String(labels[i])];
559
+ for (const d of ds) {
560
+ const data = d.data || [];
561
+ row.push(String(data[i] ?? 0));
562
+ }
563
+ lines.push(row.join(','));
564
+ }
565
+ return lines.join('\n');
566
+ }
567
+ function serializeMCQ(w) {
568
+ const parts = ['multiple-choice-question', w.id || 'id', quote(w.question || ''), arr(w.choices)];
569
+ if (w.correctAnswer)
570
+ parts.push(quote(w.correctAnswer));
571
+ if (w.showFeedback)
572
+ parts.push('showFeedback');
573
+ if (w.disabled)
574
+ parts.push('disabled');
575
+ return parts.join(' ');
576
+ }
577
+ function serializeSAQ(w) {
578
+ const parts = ['short-answer-question', w.id || 'id', quote(w.question || '')];
579
+ if (w.placeholder)
580
+ parts.push(quote(w.placeholder));
581
+ if (w.correctAnswers && w.correctAnswers.length > 0) {
582
+ parts.push(arr(w.correctAnswers));
583
+ }
584
+ else if (w.correctAnswer) {
585
+ parts.push(quote(w.correctAnswer));
586
+ }
587
+ if (w.showFeedback)
588
+ parts.push('showFeedback');
589
+ if (w.disabled)
590
+ parts.push('disabled');
591
+ return parts.join(' ');
592
+ }
593
+ function serializeQuiz(w) {
594
+ // 首行 join 成单行(quiz id "title"),配置行与题目行依次追加
595
+ const first = ['quiz', w.id || 'id', quote(w.title || '')].join(' ');
596
+ const lines = [first];
597
+ if (w.showScore !== undefined)
598
+ lines.push(`showScore: ${String(w.showScore)}`);
599
+ if (w.showProgress !== undefined)
600
+ lines.push(`showProgress: ${String(w.showProgress)}`);
601
+ if (w.passingScore !== undefined)
602
+ lines.push(`passingScore: ${w.passingScore}`);
603
+ if (w.disabled)
604
+ lines.push('disabled: true');
605
+ for (const q of (w.questions || [])) {
606
+ if (q.type === 'mcq') {
607
+ const mcq = [
608
+ 'mcq', q.id, quote(q.question),
609
+ String(q.points ?? 0), arr(q.choices || [])
610
+ ];
611
+ if (q.correctAnswer)
612
+ mcq.push(quote(q.correctAnswer));
613
+ lines.push(mcq.join(' '));
614
+ }
615
+ else if (q.type === 'short-answer') {
616
+ const sa = ['short-answer', q.id, quote(q.question), String(q.points ?? 0)];
617
+ if (q.placeholder)
618
+ sa.push(quote(q.placeholder));
619
+ if (q.correctAnswers && q.correctAnswers.length > 0) {
620
+ sa.push(arr(q.correctAnswers));
621
+ }
622
+ else if (q.correctAnswer) {
623
+ sa.push(quote(q.correctAnswer));
624
+ }
625
+ lines.push(sa.join(' '));
626
+ }
627
+ }
628
+ return lines.join('\n');
629
+ }
630
+ function serializeIncomplete(w) {
631
+ const parts = ['incomplete', w.id || 'id', quote(w.text || w.sentence || ''), quote(w.answer || '')];
632
+ return parts.join(' ');
633
+ }