cmssy-cli 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +10 -0
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +105 -12
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +12 -6
- package/dist/commands/init.js.map +1 -1
- package/dist/dev-ui/app.js +496 -0
- package/dist/dev-ui/index.html +443 -0
- package/dist/utils/block-config.d.ts.map +1 -1
- package/dist/utils/block-config.js +49 -3
- package/dist/utils/block-config.js.map +1 -1
- package/package.json +4 -3
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
// Cmssy Dev Server - Interactive UI
|
|
2
|
+
let currentBlock = null;
|
|
3
|
+
let blocks = [];
|
|
4
|
+
let previewData = {};
|
|
5
|
+
let eventSource = null;
|
|
6
|
+
|
|
7
|
+
// Initialize app
|
|
8
|
+
async function init() {
|
|
9
|
+
await loadBlocks();
|
|
10
|
+
setupSSE();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Load all blocks from API
|
|
14
|
+
async function loadBlocks() {
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch('/api/blocks');
|
|
17
|
+
blocks = await response.json();
|
|
18
|
+
renderBlocksList();
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.error('Failed to load blocks:', error);
|
|
21
|
+
document.getElementById('blocks-list').innerHTML = `
|
|
22
|
+
<div style="padding: 20px; color: #e53935;">
|
|
23
|
+
Failed to load blocks. Make sure the dev server is running.
|
|
24
|
+
</div>
|
|
25
|
+
`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Render blocks list
|
|
30
|
+
function renderBlocksList() {
|
|
31
|
+
const listEl = document.getElementById('blocks-list');
|
|
32
|
+
const countEl = document.getElementById('blocks-count');
|
|
33
|
+
|
|
34
|
+
if (blocks.length === 0) {
|
|
35
|
+
listEl.innerHTML = '<div class="editor-empty">No blocks found</div>';
|
|
36
|
+
countEl.textContent = 'No blocks';
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
countEl.textContent = `${blocks.length} ${blocks.length === 1 ? 'block' : 'blocks'}`;
|
|
41
|
+
|
|
42
|
+
listEl.innerHTML = blocks.map(block => `
|
|
43
|
+
<div
|
|
44
|
+
class="block-item ${currentBlock?.name === block.name ? 'active' : ''}"
|
|
45
|
+
data-block="${block.name}"
|
|
46
|
+
onclick="selectBlock('${block.name}')"
|
|
47
|
+
>
|
|
48
|
+
<div class="block-item-name">${block.displayName || block.name}</div>
|
|
49
|
+
<div class="block-item-type">${block.type}</div>
|
|
50
|
+
</div>
|
|
51
|
+
`).join('');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Select a block
|
|
55
|
+
async function selectBlock(blockName) {
|
|
56
|
+
const block = blocks.find(b => b.name === blockName);
|
|
57
|
+
if (!block) return;
|
|
58
|
+
|
|
59
|
+
currentBlock = block;
|
|
60
|
+
renderBlocksList(); // Update active state
|
|
61
|
+
|
|
62
|
+
// Load preview data
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch(`/api/preview/${blockName}`);
|
|
65
|
+
previewData = await response.json();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error('Failed to load preview data:', error);
|
|
68
|
+
previewData = {};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Update UI
|
|
72
|
+
document.getElementById('preview-title').textContent = block.displayName || block.name;
|
|
73
|
+
document.getElementById('editor-subtitle').textContent = block.name;
|
|
74
|
+
|
|
75
|
+
// Render preview
|
|
76
|
+
renderPreview();
|
|
77
|
+
|
|
78
|
+
// Render editor form
|
|
79
|
+
renderEditor();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Render preview iframe
|
|
83
|
+
function renderPreview() {
|
|
84
|
+
if (!currentBlock) return;
|
|
85
|
+
|
|
86
|
+
const previewContent = document.getElementById('preview-content');
|
|
87
|
+
previewContent.innerHTML = `
|
|
88
|
+
<div class="preview-iframe-wrapper">
|
|
89
|
+
<iframe
|
|
90
|
+
class="preview-iframe"
|
|
91
|
+
src="/preview/${currentBlock.name}"
|
|
92
|
+
id="preview-iframe"
|
|
93
|
+
></iframe>
|
|
94
|
+
</div>
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Render editor form
|
|
99
|
+
function renderEditor() {
|
|
100
|
+
if (!currentBlock || !currentBlock.schema) {
|
|
101
|
+
document.getElementById('editor-content').innerHTML = `
|
|
102
|
+
<div class="editor-empty">No schema defined for this block</div>
|
|
103
|
+
`;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const editorContent = document.getElementById('editor-content');
|
|
108
|
+
const fields = Object.entries(currentBlock.schema);
|
|
109
|
+
|
|
110
|
+
editorContent.innerHTML = fields.map(([key, field]) =>
|
|
111
|
+
renderField(key, field, previewData[key])
|
|
112
|
+
).join('');
|
|
113
|
+
|
|
114
|
+
// Attach event listeners
|
|
115
|
+
attachFieldListeners();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Render a single field based on type
|
|
119
|
+
function renderField(key, field, value) {
|
|
120
|
+
const required = field.required ? '<span class="field-required">*</span>' : '';
|
|
121
|
+
const helpText = field.helpText ? `<div class="field-help">${field.helpText}</div>` : '';
|
|
122
|
+
|
|
123
|
+
let inputHtml = '';
|
|
124
|
+
|
|
125
|
+
switch (field.type) {
|
|
126
|
+
case 'singleLine':
|
|
127
|
+
case 'text':
|
|
128
|
+
case 'string':
|
|
129
|
+
inputHtml = `
|
|
130
|
+
<input
|
|
131
|
+
type="text"
|
|
132
|
+
class="field-input"
|
|
133
|
+
data-field="${key}"
|
|
134
|
+
value="${escapeHtml(value || field.defaultValue || '')}"
|
|
135
|
+
placeholder="${field.placeholder || ''}"
|
|
136
|
+
${field.required ? 'required' : ''}
|
|
137
|
+
/>
|
|
138
|
+
`;
|
|
139
|
+
break;
|
|
140
|
+
|
|
141
|
+
case 'multiLine':
|
|
142
|
+
inputHtml = `
|
|
143
|
+
<textarea
|
|
144
|
+
class="field-input field-textarea"
|
|
145
|
+
data-field="${key}"
|
|
146
|
+
placeholder="${field.placeholder || ''}"
|
|
147
|
+
${field.required ? 'required' : ''}
|
|
148
|
+
>${escapeHtml(value || field.defaultValue || '')}</textarea>
|
|
149
|
+
`;
|
|
150
|
+
break;
|
|
151
|
+
|
|
152
|
+
case 'richText':
|
|
153
|
+
inputHtml = `
|
|
154
|
+
<textarea
|
|
155
|
+
class="field-input field-textarea"
|
|
156
|
+
data-field="${key}"
|
|
157
|
+
placeholder="${field.placeholder || 'Enter rich text...'}"
|
|
158
|
+
${field.required ? 'required' : ''}
|
|
159
|
+
style="min-height: 120px;"
|
|
160
|
+
>${escapeHtml(value || field.defaultValue || '')}</textarea>
|
|
161
|
+
`;
|
|
162
|
+
break;
|
|
163
|
+
|
|
164
|
+
case 'number':
|
|
165
|
+
inputHtml = `
|
|
166
|
+
<input
|
|
167
|
+
type="number"
|
|
168
|
+
class="field-input"
|
|
169
|
+
data-field="${key}"
|
|
170
|
+
value="${value !== undefined ? value : (field.defaultValue || '')}"
|
|
171
|
+
placeholder="${field.placeholder || ''}"
|
|
172
|
+
${field.required ? 'required' : ''}
|
|
173
|
+
/>
|
|
174
|
+
`;
|
|
175
|
+
break;
|
|
176
|
+
|
|
177
|
+
case 'boolean':
|
|
178
|
+
inputHtml = `
|
|
179
|
+
<label style="display: flex; align-items: center; cursor: pointer;">
|
|
180
|
+
<input
|
|
181
|
+
type="checkbox"
|
|
182
|
+
class="field-checkbox"
|
|
183
|
+
data-field="${key}"
|
|
184
|
+
${value || field.defaultValue ? 'checked' : ''}
|
|
185
|
+
/>
|
|
186
|
+
<span>${field.label}</span>
|
|
187
|
+
</label>
|
|
188
|
+
`;
|
|
189
|
+
break;
|
|
190
|
+
|
|
191
|
+
case 'date':
|
|
192
|
+
inputHtml = `
|
|
193
|
+
<input
|
|
194
|
+
type="date"
|
|
195
|
+
class="field-input"
|
|
196
|
+
data-field="${key}"
|
|
197
|
+
value="${value || field.defaultValue || ''}"
|
|
198
|
+
${field.required ? 'required' : ''}
|
|
199
|
+
/>
|
|
200
|
+
`;
|
|
201
|
+
break;
|
|
202
|
+
|
|
203
|
+
case 'link':
|
|
204
|
+
inputHtml = `
|
|
205
|
+
<input
|
|
206
|
+
type="url"
|
|
207
|
+
class="field-input"
|
|
208
|
+
data-field="${key}"
|
|
209
|
+
value="${escapeHtml(value || field.defaultValue || '')}"
|
|
210
|
+
placeholder="${field.placeholder || 'https://...'}"
|
|
211
|
+
${field.required ? 'required' : ''}
|
|
212
|
+
/>
|
|
213
|
+
`;
|
|
214
|
+
break;
|
|
215
|
+
|
|
216
|
+
case 'color':
|
|
217
|
+
const colorValue = value || field.defaultValue || '#000000';
|
|
218
|
+
inputHtml = `
|
|
219
|
+
<div class="color-field">
|
|
220
|
+
<input
|
|
221
|
+
type="color"
|
|
222
|
+
class="color-preview"
|
|
223
|
+
data-field="${key}"
|
|
224
|
+
value="${colorValue}"
|
|
225
|
+
/>
|
|
226
|
+
<input
|
|
227
|
+
type="text"
|
|
228
|
+
class="field-input color-input"
|
|
229
|
+
data-field="${key}-text"
|
|
230
|
+
value="${colorValue}"
|
|
231
|
+
placeholder="#000000"
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
`;
|
|
235
|
+
break;
|
|
236
|
+
|
|
237
|
+
case 'select':
|
|
238
|
+
const currentValue = value || field.defaultValue || '';
|
|
239
|
+
inputHtml = `
|
|
240
|
+
<select class="field-input field-select" data-field="${key}" ${field.required ? 'required' : ''}>
|
|
241
|
+
<option value="">Select an option...</option>
|
|
242
|
+
${field.options.map(opt => {
|
|
243
|
+
const optValue = typeof opt === 'string' ? opt : opt.value;
|
|
244
|
+
const optLabel = typeof opt === 'string' ? opt : opt.label;
|
|
245
|
+
return `<option value="${escapeHtml(optValue)}" ${currentValue === optValue ? 'selected' : ''}>${escapeHtml(optLabel)}</option>`;
|
|
246
|
+
}).join('')}
|
|
247
|
+
</select>
|
|
248
|
+
`;
|
|
249
|
+
break;
|
|
250
|
+
|
|
251
|
+
case 'media':
|
|
252
|
+
const mediaValue = value || field.defaultValue || {};
|
|
253
|
+
inputHtml = `
|
|
254
|
+
<div class="media-field">
|
|
255
|
+
<div class="media-preview">
|
|
256
|
+
${mediaValue.url ?
|
|
257
|
+
`<img src="${escapeHtml(mediaValue.url)}" alt="${escapeHtml(mediaValue.alt || '')}"/>` :
|
|
258
|
+
'<div class="media-placeholder">No image</div>'
|
|
259
|
+
}
|
|
260
|
+
</div>
|
|
261
|
+
<div class="media-input-group">
|
|
262
|
+
<input
|
|
263
|
+
type="url"
|
|
264
|
+
class="field-input"
|
|
265
|
+
data-field="${key}.url"
|
|
266
|
+
value="${escapeHtml(mediaValue.url || '')}"
|
|
267
|
+
placeholder="Image URL"
|
|
268
|
+
style="margin-bottom: 8px;"
|
|
269
|
+
/>
|
|
270
|
+
<input
|
|
271
|
+
type="text"
|
|
272
|
+
class="field-input"
|
|
273
|
+
data-field="${key}.alt"
|
|
274
|
+
value="${escapeHtml(mediaValue.alt || '')}"
|
|
275
|
+
placeholder="Alt text"
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
`;
|
|
280
|
+
break;
|
|
281
|
+
|
|
282
|
+
case 'repeater':
|
|
283
|
+
inputHtml = renderRepeaterField(key, field, value || field.defaultValue || []);
|
|
284
|
+
break;
|
|
285
|
+
|
|
286
|
+
default:
|
|
287
|
+
inputHtml = `<div style="color: #999;">Unsupported field type: ${field.type}</div>`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Special case for boolean - don't show separate label
|
|
291
|
+
if (field.type === 'boolean') {
|
|
292
|
+
return `
|
|
293
|
+
<div class="field-group">
|
|
294
|
+
${inputHtml}
|
|
295
|
+
${helpText}
|
|
296
|
+
</div>
|
|
297
|
+
`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return `
|
|
301
|
+
<div class="field-group">
|
|
302
|
+
<label class="field-label">
|
|
303
|
+
${field.label}${required}
|
|
304
|
+
</label>
|
|
305
|
+
${inputHtml}
|
|
306
|
+
${helpText}
|
|
307
|
+
</div>
|
|
308
|
+
`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Render repeater field
|
|
312
|
+
function renderRepeaterField(key, field, items) {
|
|
313
|
+
const minItems = field.minItems || 0;
|
|
314
|
+
const maxItems = field.maxItems || 999;
|
|
315
|
+
|
|
316
|
+
const itemsHtml = items.map((item, index) => {
|
|
317
|
+
const nestedFields = Object.entries(field.schema || {}).map(([nestedKey, nestedField]) => {
|
|
318
|
+
return renderField(`${key}.${index}.${nestedKey}`, nestedField, item[nestedKey]);
|
|
319
|
+
}).join('');
|
|
320
|
+
|
|
321
|
+
return `
|
|
322
|
+
<div class="repeater-item" data-repeater-item="${key}.${index}">
|
|
323
|
+
<div class="repeater-item-header">
|
|
324
|
+
<div class="repeater-item-title">Item ${index + 1}</div>
|
|
325
|
+
${items.length > minItems ? `
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
class="repeater-item-remove"
|
|
329
|
+
onclick="removeRepeaterItem('${key}', ${index})"
|
|
330
|
+
>Remove</button>
|
|
331
|
+
` : ''}
|
|
332
|
+
</div>
|
|
333
|
+
${nestedFields}
|
|
334
|
+
</div>
|
|
335
|
+
`;
|
|
336
|
+
}).join('');
|
|
337
|
+
|
|
338
|
+
return `
|
|
339
|
+
<div class="repeater-items" data-repeater="${key}">
|
|
340
|
+
${itemsHtml || '<div style="padding: 12px; color: #999; text-align: center;">No items yet</div>'}
|
|
341
|
+
</div>
|
|
342
|
+
${items.length < maxItems ? `
|
|
343
|
+
<button
|
|
344
|
+
type="button"
|
|
345
|
+
class="repeater-add"
|
|
346
|
+
onclick="addRepeaterItem('${key}')"
|
|
347
|
+
>+ Add Item</button>
|
|
348
|
+
` : ''}
|
|
349
|
+
`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Add repeater item
|
|
353
|
+
window.addRepeaterItem = function(key) {
|
|
354
|
+
const field = currentBlock.schema[key];
|
|
355
|
+
if (!field || !field.schema) return;
|
|
356
|
+
|
|
357
|
+
// Get current items
|
|
358
|
+
const currentItems = previewData[key] || [];
|
|
359
|
+
|
|
360
|
+
// Create new empty item
|
|
361
|
+
const newItem = {};
|
|
362
|
+
Object.keys(field.schema).forEach(nestedKey => {
|
|
363
|
+
const nestedField = field.schema[nestedKey];
|
|
364
|
+
newItem[nestedKey] = nestedField.defaultValue || '';
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Add to preview data
|
|
368
|
+
previewData[key] = [...currentItems, newItem];
|
|
369
|
+
|
|
370
|
+
// Re-render editor and save
|
|
371
|
+
renderEditor();
|
|
372
|
+
savePreviewData();
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// Remove repeater item
|
|
376
|
+
window.removeRepeaterItem = function(key, index) {
|
|
377
|
+
const currentItems = previewData[key] || [];
|
|
378
|
+
previewData[key] = currentItems.filter((_, i) => i !== index);
|
|
379
|
+
|
|
380
|
+
// Re-render editor and save
|
|
381
|
+
renderEditor();
|
|
382
|
+
savePreviewData();
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// Attach event listeners to form fields
|
|
386
|
+
function attachFieldListeners() {
|
|
387
|
+
const inputs = document.querySelectorAll('[data-field]');
|
|
388
|
+
inputs.forEach(input => {
|
|
389
|
+
const eventType = input.type === 'checkbox' ? 'change' : 'input';
|
|
390
|
+
input.addEventListener(eventType, handleFieldChange);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Handle field value change
|
|
395
|
+
function handleFieldChange(event) {
|
|
396
|
+
const input = event.target;
|
|
397
|
+
const fieldPath = input.dataset.field;
|
|
398
|
+
const field = fieldPath.split('.');
|
|
399
|
+
|
|
400
|
+
let value;
|
|
401
|
+
if (input.type === 'checkbox') {
|
|
402
|
+
value = input.checked;
|
|
403
|
+
} else if (input.type === 'number') {
|
|
404
|
+
value = input.value ? parseFloat(input.value) : '';
|
|
405
|
+
} else {
|
|
406
|
+
value = input.value;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Handle nested fields (e.g., "items.0.name" or "image.url")
|
|
410
|
+
if (field.length === 1) {
|
|
411
|
+
previewData[field[0]] = value;
|
|
412
|
+
} else if (field.length === 2) {
|
|
413
|
+
if (!previewData[field[0]]) previewData[field[0]] = {};
|
|
414
|
+
previewData[field[0]][field[1]] = value;
|
|
415
|
+
} else if (field.length === 3) {
|
|
416
|
+
// Repeater item field
|
|
417
|
+
if (!previewData[field[0]]) previewData[field[0]] = [];
|
|
418
|
+
if (!previewData[field[0]][field[1]]) previewData[field[0]][field[1]] = {};
|
|
419
|
+
previewData[field[0]][field[1]][field[2]] = value;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Sync color picker with text input
|
|
423
|
+
if (fieldPath.endsWith('-text')) {
|
|
424
|
+
const colorKey = fieldPath.replace('-text', '');
|
|
425
|
+
const colorInput = document.querySelector(`[data-field="${colorKey}"]`);
|
|
426
|
+
if (colorInput) colorInput.value = value;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Debounce save (quick debounce since we're using postMessage for instant updates)
|
|
430
|
+
clearTimeout(window.saveTimeout);
|
|
431
|
+
window.saveTimeout = setTimeout(() => savePreviewData(), 200);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Save preview data to server
|
|
435
|
+
async function savePreviewData() {
|
|
436
|
+
if (!currentBlock) return;
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
// Update preview iframe immediately (no reload/blink)
|
|
440
|
+
const iframe = document.getElementById('preview-iframe');
|
|
441
|
+
if (iframe && iframe.contentWindow) {
|
|
442
|
+
iframe.contentWindow.postMessage({
|
|
443
|
+
type: 'UPDATE_PROPS',
|
|
444
|
+
props: previewData
|
|
445
|
+
}, '*');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Save to server in background
|
|
449
|
+
const response = await fetch(`/api/preview/${currentBlock.name}`, {
|
|
450
|
+
method: 'POST',
|
|
451
|
+
headers: { 'Content-Type': 'application/json' },
|
|
452
|
+
body: JSON.stringify(previewData)
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
if (response.ok) {
|
|
456
|
+
document.getElementById('preview-status').textContent = 'Saved';
|
|
457
|
+
setTimeout(() => {
|
|
458
|
+
document.getElementById('preview-status').textContent = 'Ready';
|
|
459
|
+
}, 1000);
|
|
460
|
+
}
|
|
461
|
+
} catch (error) {
|
|
462
|
+
console.error('Failed to save preview data:', error);
|
|
463
|
+
document.getElementById('preview-status').textContent = 'Error';
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Setup Server-Sent Events for hot reload
|
|
468
|
+
function setupSSE() {
|
|
469
|
+
eventSource = new EventSource('/events');
|
|
470
|
+
|
|
471
|
+
eventSource.onmessage = (event) => {
|
|
472
|
+
const data = JSON.parse(event.data);
|
|
473
|
+
|
|
474
|
+
if (data.type === 'reload') {
|
|
475
|
+
// Reload preview iframe
|
|
476
|
+
const iframe = document.getElementById('preview-iframe');
|
|
477
|
+
if (iframe && (!data.block || data.block === currentBlock?.name)) {
|
|
478
|
+
iframe.src = iframe.src; // Force reload
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
eventSource.onerror = () => {
|
|
484
|
+
console.error('SSE connection lost. Reconnecting...');
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Utility: Escape HTML
|
|
489
|
+
function escapeHtml(text) {
|
|
490
|
+
const div = document.createElement('div');
|
|
491
|
+
div.textContent = text;
|
|
492
|
+
return div.innerHTML;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Start the app
|
|
496
|
+
init();
|