infaira-canvas 0.1.9
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/README.md +264 -0
- package/dist/commands/init.d.ts +17 -0
- package/dist/commands/init.js +647 -0
- package/dist/commands/upload.d.ts +8 -0
- package/dist/commands/upload.js +164 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +123 -0
- package/package.json +44 -0
- package/templates/ICan-Customizing-Components.md +195 -0
- package/templates/ICan-Widget-Development-Guide.md +500 -0
- package/templates/ICan-Widget-Styling-Patterns.md +890 -0
- package/templates/ICan-Widget-Theming-Guide.md +633 -0
- package/templates/README.md +127 -0
- package/templates/designer.d.ts +468 -0
- package/templates/ican.d.ts +763 -0
- package/templates/index.html +2225 -0
- package/templates/resources/favicon.ico +2 -0
- package/templates/resources/ican-components.js +1734 -0
- package/templates/resources/infaira-icon.png +0 -0
- package/templates/resources/infaira-logo.png +0 -0
- package/templates/site.webmanifest +17 -0
- package/templates/ui.html +1670 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import https from 'https';
|
|
5
|
+
import http from 'http';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
// ─── Remote fetch ─────────────────────────────────────────────────────────────
|
|
8
|
+
const ICAN_TYPES_URL = 'https://infaira.ai/dist/client/ican.d.ts';
|
|
9
|
+
function fetchText(url) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const client = url.startsWith('https') ? https : http;
|
|
12
|
+
client.get(url, (res) => {
|
|
13
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
14
|
+
fetchText(res.headers.location).then(resolve).catch(reject);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (res.statusCode !== 200) {
|
|
18
|
+
reject(new Error(`HTTP ${res.statusCode ?? 'unknown'} fetching ${url}`));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const chunks = [];
|
|
22
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
23
|
+
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
24
|
+
res.on('error', reject);
|
|
25
|
+
}).on('error', reject);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
29
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
30
|
+
const __dirname = path.dirname(__filename);
|
|
31
|
+
function toKebabCase(name) {
|
|
32
|
+
return name
|
|
33
|
+
.trim()
|
|
34
|
+
// Insert hyphen before an uppercase letter that follows a lowercase letter or digit
|
|
35
|
+
// e.g. "SampleWidget" → "Sample-Widget", "myWidget" → "my-Widget"
|
|
36
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
37
|
+
// Insert hyphen between a run of uppercase letters and the start of a new word
|
|
38
|
+
// e.g. "XMLParser" → "XML-Parser"
|
|
39
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
42
|
+
.replace(/^-+|-+$/g, '');
|
|
43
|
+
}
|
|
44
|
+
function toPascalCase(name) {
|
|
45
|
+
return toKebabCase(name)
|
|
46
|
+
.split('-')
|
|
47
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
48
|
+
.join('');
|
|
49
|
+
}
|
|
50
|
+
function toTitleCase(name) {
|
|
51
|
+
return toKebabCase(name)
|
|
52
|
+
.split('-')
|
|
53
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
54
|
+
.join(' ');
|
|
55
|
+
}
|
|
56
|
+
function writeFile(dir, relPath, content) {
|
|
57
|
+
const fullPath = path.join(dir, relPath);
|
|
58
|
+
const parentDir = path.dirname(fullPath);
|
|
59
|
+
try {
|
|
60
|
+
if (!fs.existsSync(parentDir)) {
|
|
61
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
64
|
+
console.log(` Writing ${relPath}`);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
68
|
+
console.error(` Error writing ${relPath}: ${msg}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function copyTemplate(targetDir, templateRelPath, destRelPath) {
|
|
73
|
+
const templatePath = path.join(__dirname, '..', '..', 'templates', templateRelPath);
|
|
74
|
+
const destPath = path.join(targetDir, destRelPath);
|
|
75
|
+
const parentDir = path.dirname(destPath);
|
|
76
|
+
try {
|
|
77
|
+
if (!fs.existsSync(parentDir)) {
|
|
78
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
fs.copyFileSync(templatePath, destPath);
|
|
81
|
+
console.log(` Copying ${destRelPath}`);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
85
|
+
console.error(` Error copying ${destRelPath}: ${msg}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// ─── Template generators ──────────────────────────────────────────────────────
|
|
90
|
+
function makeBundleJson(bundleId, widgetId, widgetName) {
|
|
91
|
+
return JSON.stringify({
|
|
92
|
+
id: bundleId,
|
|
93
|
+
name: widgetName,
|
|
94
|
+
version: '1.0.0',
|
|
95
|
+
author: '',
|
|
96
|
+
widgets: [
|
|
97
|
+
{
|
|
98
|
+
id: widgetId,
|
|
99
|
+
name: widgetName,
|
|
100
|
+
description: 'A sample widget',
|
|
101
|
+
icon: '',
|
|
102
|
+
tags: [],
|
|
103
|
+
category: '',
|
|
104
|
+
isTemplate: false,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
sidebarLinks: [],
|
|
108
|
+
uis: [],
|
|
109
|
+
menuItems: [],
|
|
110
|
+
}, null, 2);
|
|
111
|
+
}
|
|
112
|
+
function makeSampleBundleJson(bundleId, widgetId, widgetName) {
|
|
113
|
+
// Valid JSON — no comments allowed in JSON
|
|
114
|
+
return JSON.stringify({
|
|
115
|
+
id: bundleId,
|
|
116
|
+
author: '',
|
|
117
|
+
widgets: [
|
|
118
|
+
{
|
|
119
|
+
id: widgetId,
|
|
120
|
+
name: widgetName,
|
|
121
|
+
description: 'A sample widget',
|
|
122
|
+
icon: '',
|
|
123
|
+
tags: [],
|
|
124
|
+
category: '',
|
|
125
|
+
isTemplate: false,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
sidebarLinks: [
|
|
129
|
+
{
|
|
130
|
+
id: `${widgetId}-link`,
|
|
131
|
+
label: '',
|
|
132
|
+
description: '',
|
|
133
|
+
target: '',
|
|
134
|
+
icon: '',
|
|
135
|
+
roles: [],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
uis: [
|
|
139
|
+
{
|
|
140
|
+
id: `${widgetId}-ui`,
|
|
141
|
+
label: '',
|
|
142
|
+
description: '',
|
|
143
|
+
roles: [],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
menuItems: [
|
|
147
|
+
{
|
|
148
|
+
id: `${widgetId}-menu`,
|
|
149
|
+
title: widgetName,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
}, null, 2);
|
|
153
|
+
}
|
|
154
|
+
function makeSampleLocalizationJson() {
|
|
155
|
+
return JSON.stringify({
|
|
156
|
+
'ican.widget-title': {
|
|
157
|
+
en: 'Widget Title',
|
|
158
|
+
ar: '<arabic translation>',
|
|
159
|
+
},
|
|
160
|
+
}, null, 2);
|
|
161
|
+
}
|
|
162
|
+
function makeIndexTsx(componentName, widgetName, widgetId) {
|
|
163
|
+
return `import * as React from 'react';
|
|
164
|
+
import { registerWidget } from './ican';
|
|
165
|
+
import type { IContextProvider } from './ican';
|
|
166
|
+
import './styles.scss';
|
|
167
|
+
|
|
168
|
+
export interface IWidgetProps {
|
|
169
|
+
icanContext?: IContextProvider;
|
|
170
|
+
instanceId?: string;
|
|
171
|
+
[key: string]: unknown;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const ${componentName}: React.FC<IWidgetProps> = ({ icanContext, instanceId: _instanceId }) => {
|
|
175
|
+
const [data, setData] = React.useState<unknown>(null);
|
|
176
|
+
const [loading, setLoading] = React.useState(true);
|
|
177
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
178
|
+
|
|
179
|
+
React.useEffect(() => {
|
|
180
|
+
if (!icanContext) {
|
|
181
|
+
setLoading(false);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// Example: fetch data from Orch
|
|
185
|
+
// icanContext.executeAction('MyModel', 'GetAll', {}, { json: true })
|
|
186
|
+
// .then((result) => { setData(result); setLoading(false); })
|
|
187
|
+
// .catch((err: unknown) => { setError(String(err)); setLoading(false); });
|
|
188
|
+
setData('Hello from ${widgetName}!');
|
|
189
|
+
setLoading(false);
|
|
190
|
+
}, [icanContext]);
|
|
191
|
+
|
|
192
|
+
if (loading) {
|
|
193
|
+
return (
|
|
194
|
+
<div className="widget-loading">
|
|
195
|
+
<div className="widget-spinner" />
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (error) {
|
|
201
|
+
return (
|
|
202
|
+
<div className="widget-error">
|
|
203
|
+
<p className="widget-error-message">{error}</p>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div className="widget-root">
|
|
210
|
+
<p>{String(data)}</p>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
registerWidget({
|
|
216
|
+
id: '${widgetId}',
|
|
217
|
+
widget: ${componentName},
|
|
218
|
+
configs: {
|
|
219
|
+
layout: {
|
|
220
|
+
// w: 10,
|
|
221
|
+
// h: 8,
|
|
222
|
+
// minW: 4,
|
|
223
|
+
// minH: 4,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
`;
|
|
228
|
+
}
|
|
229
|
+
function makeStylesScss() {
|
|
230
|
+
return `.widget-root {
|
|
231
|
+
width: 100%;
|
|
232
|
+
height: 100%;
|
|
233
|
+
box-sizing: border-box;
|
|
234
|
+
font-family: 'Comfortaa', 'Inter', system-ui, sans-serif;
|
|
235
|
+
color: #FFFFFF;
|
|
236
|
+
padding: 20px;
|
|
237
|
+
|
|
238
|
+
* { box-sizing: border-box; }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.widget-loading {
|
|
242
|
+
width: 100%;
|
|
243
|
+
height: 100%;
|
|
244
|
+
display: flex;
|
|
245
|
+
align-items: center;
|
|
246
|
+
justify-content: center;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.widget-spinner {
|
|
250
|
+
width: 32px;
|
|
251
|
+
height: 32px;
|
|
252
|
+
border: 3px solid rgba(255, 255, 255, 0.15);
|
|
253
|
+
border-top-color: rgba(255, 255, 255, 0.7);
|
|
254
|
+
border-radius: 50%;
|
|
255
|
+
animation: spin 0.8s linear infinite;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.widget-error {
|
|
259
|
+
width: 100%;
|
|
260
|
+
height: 100%;
|
|
261
|
+
display: flex;
|
|
262
|
+
align-items: center;
|
|
263
|
+
justify-content: center;
|
|
264
|
+
padding: 20px;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.widget-error-message {
|
|
268
|
+
font-family: 'Comfortaa', sans-serif;
|
|
269
|
+
font-size: 0.85rem;
|
|
270
|
+
color: rgba(249, 115, 96, 0.9);
|
|
271
|
+
text-align: center;
|
|
272
|
+
background: rgba(249, 115, 96, 0.08);
|
|
273
|
+
border: 1px solid rgba(249, 115, 96, 0.25);
|
|
274
|
+
border-radius: 10px;
|
|
275
|
+
padding: 14px 20px;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
@keyframes spin {
|
|
279
|
+
to { transform: rotate(360deg); }
|
|
280
|
+
}
|
|
281
|
+
`;
|
|
282
|
+
}
|
|
283
|
+
function makeIcanTs() {
|
|
284
|
+
return `/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
285
|
+
import BundleConfig from '../bundle.json';
|
|
286
|
+
import LocalizationMessages from '../localization.json';
|
|
287
|
+
|
|
288
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
export interface IActionOptions {
|
|
291
|
+
json?: boolean;
|
|
292
|
+
key?: string;
|
|
293
|
+
cancelPrevious?: boolean;
|
|
294
|
+
executeImmediately?: boolean;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export interface IContextProvider {
|
|
298
|
+
environment: 'dev' | 'prod';
|
|
299
|
+
orchUrl?: string;
|
|
300
|
+
userKey: string;
|
|
301
|
+
root: string;
|
|
302
|
+
scriptFiles?: string[];
|
|
303
|
+
executeAction(model: string, action: string, parameters: unknown, options?: IActionOptions): Promise<unknown>;
|
|
304
|
+
executeService(app: string, service: string, parameters: unknown, options?: IActionOptions): Promise<unknown>;
|
|
305
|
+
fireEvent(eventId: string): Promise<void>;
|
|
306
|
+
hasAppRole(app: string, role: string): boolean;
|
|
307
|
+
themeName?: string;
|
|
308
|
+
themeType?: 'Light' | 'Dark' | 'Glass-Dark' | 'Glass-Light';
|
|
309
|
+
language: string;
|
|
310
|
+
$L(code: string, params?: Record<string, string>): string;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface ILayout {
|
|
314
|
+
w?: number; h?: number; minW?: number; minH?: number;
|
|
315
|
+
maxW?: number; maxH?: number; isDraggable?: boolean; isResizable?: boolean; static?: boolean;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface IWidgetPropConfig {
|
|
319
|
+
name: string; label: string;
|
|
320
|
+
type: 'text' | 'string' | 'password' | 'number' | 'email' | 'checkbox' | 'toggle' | 'select' | 'date' | 'time' | 'json';
|
|
321
|
+
value?: string | number | boolean;
|
|
322
|
+
placeholder?: string;
|
|
323
|
+
options?: Array<{ label: string; value: string }>;
|
|
324
|
+
validate?: { required?: boolean; minLength?: number; maxLength?: number; minVal?: number; maxVal?: number; };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export interface IWidgetObject {
|
|
328
|
+
id: string;
|
|
329
|
+
widget: React.ComponentType<any>;
|
|
330
|
+
configs?: { layout?: ILayout; props?: IWidgetPropConfig[]; preLoader?: string; };
|
|
331
|
+
defaultProps?: Record<string, unknown>;
|
|
332
|
+
isTemplate?: boolean;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface ISidebarLink { id: string; click?: () => void; component?: unknown; link?: string; }
|
|
336
|
+
export interface IRenderUIItemProps { id: string; component: unknown; uiProps?: unknown; showDefaultHeader?: boolean; }
|
|
337
|
+
export interface IMenuItem { id: string; title?: string; content: () => unknown; link?: string; component?: unknown; }
|
|
338
|
+
|
|
339
|
+
declare global {
|
|
340
|
+
interface Window {
|
|
341
|
+
registerWidget(config: unknown): void;
|
|
342
|
+
registerLink(config: unknown): void;
|
|
343
|
+
registerUI(config: unknown): void;
|
|
344
|
+
registerMenuItem(config: unknown): void;
|
|
345
|
+
registerLocalization(messages: unknown): void;
|
|
346
|
+
ICanComponents: Record<string, unknown>;
|
|
347
|
+
WidgetDesignerComponents: Record<string, unknown>;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── Registration ─────────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
export function registerWidget(_widget: IWidgetObject): void {
|
|
354
|
+
const id = ((BundleConfig.id as string) + '/widget/' + _widget.id).toLowerCase();
|
|
355
|
+
|
|
356
|
+
if (!(window as any).registerWidget) {
|
|
357
|
+
console.error('[ICan] registerWidget called outside of ICan portal context');
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const widgetDetails = (BundleConfig.widgets as Array<{ id: string }>)?.find(w => w.id === _widget.id);
|
|
362
|
+
if (!widgetDetails) {
|
|
363
|
+
throw new Error(\`[ICan] Widget "\${_widget.id}" is not in bundle.json. Add it to the widgets array first.\`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const merged: Record<string, unknown> = { ..._widget, ...widgetDetails, id };
|
|
367
|
+
if (merged['widget'] && !merged['component']) {
|
|
368
|
+
merged['component'] = merged['widget'];
|
|
369
|
+
}
|
|
370
|
+
(window as any).registerWidget(merged);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function registerLink(_link: ISidebarLink): void {
|
|
374
|
+
const id = ((BundleConfig.id as string) + '/sidebarlink/' + _link.id).toLowerCase();
|
|
375
|
+
if (!(window as any).registerLink) { console.error('[ICan] Not in ICan portal context'); return; }
|
|
376
|
+
const details = (BundleConfig.sidebarLinks as Array<{ id: string }>)?.find(s => s.id === _link.id);
|
|
377
|
+
if (!details) throw new Error(\`[ICan] Sidebar link "\${_link.id}" not in bundle.json\`);
|
|
378
|
+
(window as any).registerLink({ ..._link, ...details, id });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function registerUI(_ui: IRenderUIItemProps): void {
|
|
382
|
+
const id = ((BundleConfig.id as string) + '/ui/' + _ui.id).toLowerCase();
|
|
383
|
+
if (!(window as any).registerUI) { console.error('[ICan] Not in ICan portal context'); return; }
|
|
384
|
+
const details = (BundleConfig.uis as Array<{ id: string }>)?.find(s => s.id === _ui.id);
|
|
385
|
+
if (!details) throw new Error(\`[ICan] UI "\${_ui.id}" not in bundle.json\`);
|
|
386
|
+
(window as any).registerUI({ ..._ui, ...details, id });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function registerMenuItem(_menuItem: IMenuItem): void {
|
|
390
|
+
const id = ((BundleConfig.id as string) + '/menuitem/' + _menuItem.id).toLowerCase();
|
|
391
|
+
if (!(window as any).registerMenuItem) { console.error('[ICan] Not in ICan portal context'); return; }
|
|
392
|
+
const details = (BundleConfig.menuItems as Array<{ id: string }>)?.find(s => s.id === _menuItem.id);
|
|
393
|
+
if (!details) throw new Error(\`[ICan] Menu item "\${_menuItem.id}" not in bundle.json\`);
|
|
394
|
+
(window as any).registerMenuItem({ ..._menuItem, ...details, id });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function enableLocalization(): void {
|
|
398
|
+
(window as any).registerLocalization(LocalizationMessages);
|
|
399
|
+
}
|
|
400
|
+
`;
|
|
401
|
+
}
|
|
402
|
+
function makeWebpackConfig() {
|
|
403
|
+
return `var path = require('path');
|
|
404
|
+
|
|
405
|
+
module.exports = {
|
|
406
|
+
mode: "development",
|
|
407
|
+
|
|
408
|
+
devtool: "source-map",
|
|
409
|
+
|
|
410
|
+
resolve: {
|
|
411
|
+
extensions: [".ts", ".tsx", ".js", ".json"]
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
entry: "./src/index.tsx",
|
|
415
|
+
output: {
|
|
416
|
+
path: path.join(__dirname, '/dist'),
|
|
417
|
+
publicPath: "/dist/",
|
|
418
|
+
filename: '[name].js'
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
module: {
|
|
422
|
+
rules: [
|
|
423
|
+
{
|
|
424
|
+
test: /\\.css$/,
|
|
425
|
+
use: ["style-loader", "css-loader"],
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
test: /\\.scss$/,
|
|
429
|
+
use: ["style-loader", "css-loader", { loader: "sass-loader", options: { api: "modern" } }]
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
test: /\\.ts(x?)$/,
|
|
433
|
+
exclude: /node_modules/,
|
|
434
|
+
use: [{ loader: "ts-loader" }]
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
test: /\\.svg$/,
|
|
438
|
+
use: [{ loader: 'svg-url-loader', options: { limit: 10000 } }],
|
|
439
|
+
},
|
|
440
|
+
{ enforce: "pre", test: /\\.js$/, loader: "source-map-loader" }
|
|
441
|
+
]
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
// React and portal-provided libs stay external — never bundle them.
|
|
445
|
+
// Everything else (e.g. apexcharts) gets bundled into main.js automatically.
|
|
446
|
+
externals: {
|
|
447
|
+
"react": "React",
|
|
448
|
+
"react-dom": "ReactDOM",
|
|
449
|
+
"recharts": "Recharts",
|
|
450
|
+
"ican/components": "ICanComponents",
|
|
451
|
+
"widget-designer/components": "WidgetDesignerComponents",
|
|
452
|
+
},
|
|
453
|
+
|
|
454
|
+
devServer: {
|
|
455
|
+
static: { directory: path.join(__dirname, '/') },
|
|
456
|
+
compress: true,
|
|
457
|
+
liveReload: true,
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
`;
|
|
461
|
+
}
|
|
462
|
+
function makeWidgetTsConfig() {
|
|
463
|
+
return JSON.stringify({
|
|
464
|
+
compilerOptions: {
|
|
465
|
+
outDir: './dist/',
|
|
466
|
+
sourceMap: true,
|
|
467
|
+
strict: true,
|
|
468
|
+
noImplicitAny: true,
|
|
469
|
+
strictNullChecks: true,
|
|
470
|
+
noUnusedLocals: true,
|
|
471
|
+
noUnusedParameters: true,
|
|
472
|
+
noImplicitReturns: true,
|
|
473
|
+
skipLibCheck: true,
|
|
474
|
+
module: 'commonjs',
|
|
475
|
+
target: 'es6',
|
|
476
|
+
jsx: 'react',
|
|
477
|
+
resolveJsonModule: true,
|
|
478
|
+
esModuleInterop: true,
|
|
479
|
+
},
|
|
480
|
+
include: ['src/**/*', 'ican.d.ts', 'designer.d.ts'],
|
|
481
|
+
}, null, 2);
|
|
482
|
+
}
|
|
483
|
+
function makeWidgetPackageJson(widgetId) {
|
|
484
|
+
return JSON.stringify({
|
|
485
|
+
name: widgetId,
|
|
486
|
+
version: '1.0.0',
|
|
487
|
+
private: true,
|
|
488
|
+
scripts: {
|
|
489
|
+
build: 'webpack --config webpack.config.js --mode production --progress',
|
|
490
|
+
'build:dev': 'webpack --config webpack.config.js',
|
|
491
|
+
watch: 'webpack --watch --config webpack.config.js',
|
|
492
|
+
dev: 'webpack serve',
|
|
493
|
+
typecheck: 'tsc --noEmit',
|
|
494
|
+
},
|
|
495
|
+
dependencies: {
|
|
496
|
+
react: '^16.13.1',
|
|
497
|
+
'react-dom': '^16.13.1',
|
|
498
|
+
},
|
|
499
|
+
devDependencies: {
|
|
500
|
+
'@types/react': '^16.9.43',
|
|
501
|
+
'@types/react-dom': '^16.9.8',
|
|
502
|
+
'css-loader': '^6.8.1',
|
|
503
|
+
sass: '^1.69.0',
|
|
504
|
+
'sass-loader': '^13.3.2',
|
|
505
|
+
'source-map-loader': '^4.0.1',
|
|
506
|
+
'style-loader': '^3.3.3',
|
|
507
|
+
'svg-url-loader': '^8.0.0',
|
|
508
|
+
'ts-loader': '^9.5.0',
|
|
509
|
+
typescript: '^5.2.0',
|
|
510
|
+
webpack: '^5.88.0',
|
|
511
|
+
'webpack-cli': '^5.1.4',
|
|
512
|
+
'webpack-dev-server': '^4.15.1',
|
|
513
|
+
},
|
|
514
|
+
}, null, 2);
|
|
515
|
+
}
|
|
516
|
+
function makeEnvExample(portalUrl) {
|
|
517
|
+
return `# ICan portal URL — used by infaira-canvas upload
|
|
518
|
+
ICAN_URL=${portalUrl}
|
|
519
|
+
|
|
520
|
+
# Auth token — run "infaira-canvas login" or paste your JWT here
|
|
521
|
+
# NEVER commit the real .env file to git
|
|
522
|
+
ICAN_TOKEN=
|
|
523
|
+
`;
|
|
524
|
+
}
|
|
525
|
+
export function validateBundleJson(raw) {
|
|
526
|
+
let parsed;
|
|
527
|
+
try {
|
|
528
|
+
parsed = JSON.parse(raw);
|
|
529
|
+
}
|
|
530
|
+
catch (e) {
|
|
531
|
+
return { valid: false, error: `bundle.json is not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
|
|
532
|
+
}
|
|
533
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
534
|
+
return { valid: false, error: 'bundle.json must be a JSON object' };
|
|
535
|
+
}
|
|
536
|
+
const obj = parsed;
|
|
537
|
+
if (typeof obj['id'] !== 'string' || obj['id'].trim() === '') {
|
|
538
|
+
return { valid: false, error: 'bundle.json is missing required field "id" (string)' };
|
|
539
|
+
}
|
|
540
|
+
if (!Array.isArray(obj['widgets']) || obj['widgets'].length === 0) {
|
|
541
|
+
return { valid: false, error: 'bundle.json must have a non-empty "widgets" array' };
|
|
542
|
+
}
|
|
543
|
+
for (const w of obj['widgets']) {
|
|
544
|
+
if (typeof w !== 'object' || w === null) {
|
|
545
|
+
return { valid: false, error: 'Each entry in "widgets" must be an object' };
|
|
546
|
+
}
|
|
547
|
+
const widget = w;
|
|
548
|
+
if (typeof widget['id'] !== 'string' || widget['id'].trim() === '') {
|
|
549
|
+
return { valid: false, error: 'Each widget must have a non-empty "id" string' };
|
|
550
|
+
}
|
|
551
|
+
if (typeof widget['name'] !== 'string' || widget['name'].trim() === '') {
|
|
552
|
+
return { valid: false, error: `Widget "${widget['id']}" is missing a "name" field` };
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return { valid: true, data: obj };
|
|
556
|
+
}
|
|
557
|
+
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
558
|
+
export async function handleInit(name) {
|
|
559
|
+
const rawName = name.trim();
|
|
560
|
+
const kebabName = toKebabCase(name);
|
|
561
|
+
if (!kebabName) {
|
|
562
|
+
console.error('Error: widget name must contain at least one alphanumeric character.');
|
|
563
|
+
process.exit(1);
|
|
564
|
+
}
|
|
565
|
+
const widgetTitle = toTitleCase(name);
|
|
566
|
+
const componentName = toPascalCase(name);
|
|
567
|
+
const bundleId = crypto.randomUUID();
|
|
568
|
+
const widgetId = kebabName;
|
|
569
|
+
const targetDir = path.resolve(process.cwd(), rawName);
|
|
570
|
+
if (fs.existsSync(targetDir)) {
|
|
571
|
+
console.error(`Error: directory "${rawName}" already exists.`);
|
|
572
|
+
process.exit(1);
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
579
|
+
console.error(`Error: could not create directory "${rawName}": ${msg}`);
|
|
580
|
+
process.exit(1);
|
|
581
|
+
}
|
|
582
|
+
console.log(`\nScaffolding widget: ${widgetTitle}\n`);
|
|
583
|
+
// ── ican.d.ts — fetch from server, fall back to bundled template ──
|
|
584
|
+
process.stdout.write(` Fetching ican.d.ts from ${ICAN_TYPES_URL} ...\n`);
|
|
585
|
+
let icanDts;
|
|
586
|
+
try {
|
|
587
|
+
icanDts = await fetchText(ICAN_TYPES_URL);
|
|
588
|
+
writeFile(targetDir, 'ican.d.ts', icanDts);
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
process.stdout.write(` Warning: could not fetch remote ican.d.ts — using bundled version.\n`);
|
|
592
|
+
copyTemplate(targetDir, 'ican.d.ts', 'ican.d.ts');
|
|
593
|
+
}
|
|
594
|
+
// ── Static template files ──
|
|
595
|
+
copyTemplate(targetDir, 'designer.d.ts', 'designer.d.ts');
|
|
596
|
+
copyTemplate(targetDir, 'index.html', 'index.html');
|
|
597
|
+
copyTemplate(targetDir, 'ui.html', 'ui.html');
|
|
598
|
+
copyTemplate(targetDir, 'resources/ican-components.js', 'resources/ican-components.js');
|
|
599
|
+
copyTemplate(targetDir, 'resources/infaira-logo.png', 'resources/infaira-logo.png');
|
|
600
|
+
copyTemplate(targetDir, 'resources/infaira-icon.png', 'resources/infaira-icon.png');
|
|
601
|
+
copyTemplate(targetDir, 'resources/favicon.ico', 'resources/favicon.ico');
|
|
602
|
+
copyTemplate(targetDir, 'site.webmanifest', 'site.webmanifest');
|
|
603
|
+
copyTemplate(targetDir, 'README.md', 'README.md');
|
|
604
|
+
copyTemplate(targetDir, 'ICan-Widget-Theming-Guide.md', 'ICan-Widget-Theming-Guide.md');
|
|
605
|
+
copyTemplate(targetDir, 'ICan-Customizing-Components.md', 'ICan-Customizing-Components.md');
|
|
606
|
+
copyTemplate(targetDir, 'ICan-Widget-Styling-Patterns.md', 'ICan-Widget-Styling-Patterns.md');
|
|
607
|
+
copyTemplate(targetDir, 'ICan-Widget-Development-Guide.md', 'ICan-Widget-Development-Guide.md');
|
|
608
|
+
// ── Generated files ──
|
|
609
|
+
writeFile(targetDir, 'bundle.json', makeBundleJson(bundleId, widgetId, widgetTitle));
|
|
610
|
+
writeFile(targetDir, 'src/index.tsx', makeIndexTsx(componentName, widgetTitle, widgetId));
|
|
611
|
+
writeFile(targetDir, 'src/ican.ts', makeIcanTs());
|
|
612
|
+
writeFile(targetDir, 'src/styles.scss', makeStylesScss());
|
|
613
|
+
writeFile(targetDir, 'webpack.config.js', makeWebpackConfig());
|
|
614
|
+
writeFile(targetDir, 'tsconfig.json', makeWidgetTsConfig());
|
|
615
|
+
writeFile(targetDir, 'package.json', makeWidgetPackageJson(widgetId));
|
|
616
|
+
writeFile(targetDir, 'localization.json', '{}\n');
|
|
617
|
+
writeFile(targetDir, '.env', makeEnvExample('http://localhost:4000'));
|
|
618
|
+
writeFile(targetDir, 'sample-bundle.json', makeSampleBundleJson(bundleId, widgetId, widgetTitle));
|
|
619
|
+
writeFile(targetDir, 'sample-localization.json', makeSampleLocalizationJson());
|
|
620
|
+
writeFile(targetDir, '.gitignore', 'node_modules/\ndist/\n*.tsbuildinfo\n.DS_Store\n.env\n\n# Fetched from AWS at init time — never commit\nican.d.ts\n');
|
|
621
|
+
const box = `
|
|
622
|
+
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
|
|
623
|
+
\u2502 \u2502
|
|
624
|
+
\u2502 Widget scaffolded: ${widgetTitle.padEnd(28)} \u2502
|
|
625
|
+
\u2502 \u2502
|
|
626
|
+
\u2502 Setup: \u2502
|
|
627
|
+
\u2502 cd ${rawName.padEnd(46)} \u2502
|
|
628
|
+
\u2502 npm install \u2502
|
|
629
|
+
\u2502 \u2502
|
|
630
|
+
\u2502 Development: \u2502
|
|
631
|
+
\u2502 npm run dev \u2192 http://localhost:8080 \u2502
|
|
632
|
+
\u2502 \u2502
|
|
633
|
+
\u2502 Type-check: \u2502
|
|
634
|
+
\u2502 npm run typecheck \u2502
|
|
635
|
+
\u2502 \u2502
|
|
636
|
+
\u2502 Build for upload: \u2502
|
|
637
|
+
\u2502 npm run build \u2502
|
|
638
|
+
\u2502 \u2502
|
|
639
|
+
\u2502 Upload to portal: \u2502
|
|
640
|
+
\u2502 infaira-canvas upload \\ \u2502
|
|
641
|
+
\u2502 --bundle bundle.json \\ \u2502
|
|
642
|
+
\u2502 --script dist/main.js \u2502
|
|
643
|
+
\u2502 (reads ICAN_URL + ICAN_TOKEN from .env) \u2502
|
|
644
|
+
\u2502 \u2502
|
|
645
|
+
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518`;
|
|
646
|
+
console.log(box);
|
|
647
|
+
}
|