@aaqu/fromcubes-react 0.2.0 → 0.2.2

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.
@@ -1,12 +1,27 @@
1
1
  <!-- ============================================================
2
2
  Portal Code Workspace — sidebar tab + full-screen editor overlay.
3
- Served from nodes/editor/ via the /fromcubes-react/editor admin route.
3
+ Served from nodes/editor/ via the /fromcubes-react/<version>/editor admin route.
4
4
  ============================================================ -->
5
- <link rel="stylesheet" href="fromcubes-react/editor/workspace.css" />
6
- <script type="text/javascript" src="fromcubes-react/editor/file-tree.js"></script>
7
- <script type="text/javascript" src="fromcubes-react/editor/workspace.js"></script>
8
- <script type="text/javascript" src="fromcubes-react/editor/format.js"></script>
9
- <script type="text/javascript" src="fromcubes-react/editor/provider-settings.js"></script>
5
+ <script type="text/javascript">
6
+ // Loaded from the versioned asset path the runtime exports (ASSET_BASE in
7
+ // lib/admin-api.js), so no cache — browser, CDN or proxy — can serve this
8
+ // template an older file, or a 404 it remembered. Execution order follows
9
+ // insertion order (async = false); every script waits for what it needs.
10
+ (function () {
11
+ const base = (RED.settings && RED.settings.portalReactAssetBase) || "fromcubes-react/";
12
+ window.__fcAssetBase = base;
13
+ const link = document.createElement("link");
14
+ link.rel = "stylesheet";
15
+ link.href = base + "editor/workspace.css";
16
+ document.head.appendChild(link);
17
+ ["file-tree.js", "class-completion.js", "workspace.js", "format.js", "provider-settings.js"].forEach(function (file) {
18
+ const s = document.createElement("script");
19
+ s.src = base + "editor/" + file;
20
+ s.async = false;
21
+ document.head.appendChild(s);
22
+ });
23
+ })();
24
+ </script>
10
25
 
11
26
  <!-- ============================================================
12
27
  Shared: Monaco loader (used by both node editors)
@@ -29,12 +44,13 @@
29
44
  quickSuggestions: { strings: true },
30
45
  };
31
46
 
32
- // className="…" completion list, from the CSS engine in force (Settings →
33
- // Fromcubes). Refetched every time a dialog or the React Editor opens and
47
+ // className="…" completion data, from the CSS engine in force (Settings →
48
+ // Fromcubes): classes with their swatch colours and modifiers, and the
49
+ // variants. Refetched every time a dialog or the React Editor opens and
34
50
  // after the engine changes — the engine can change while the editor is
35
51
  // open, so "load once" would keep offering the old engine's classes.
36
52
  window.__fcCssClasses = null;
37
- window.__fcCssClassSet = null;
53
+ window.__fcCssData = null;
38
54
  window.__fcCssEngine = null;
39
55
  let cssClassesPending = false;
40
56
  window.__fcRefreshCssClasses = function () {
@@ -42,9 +58,26 @@
42
58
  cssClassesPending = true;
43
59
  $.getJSON("fromcubes-react/css-classes", function (data) {
44
60
  if (!data || !Array.isArray(data.classes)) return;
61
+ const prev = window.__fcCssData;
45
62
  window.__fcCssEngine = data.engine;
46
63
  window.__fcCssClasses = data.classes;
47
- window.__fcCssClassSet = null;
64
+ window.__fcCssData = {
65
+ engine: data.engine,
66
+ classes: data.classes,
67
+ colors: data.colors || [],
68
+ modifiers: data.modifiers || [],
69
+ classModifiers: data.classModifiers || [],
70
+ variants: data.variants || [],
71
+ describe: data.describe === true,
72
+ canonical: data.canonical === true,
73
+ };
74
+ // What the engine said about classes stays true while it is the same
75
+ // engine; a refetch on every dialog open must not ask again.
76
+ if (prev && prev.engine === data.engine && prev._described) {
77
+ Object.defineProperty(window.__fcCssData, "_described", { value: prev._described });
78
+ Object.defineProperty(window.__fcCssData, "_canonical", { value: prev._canonical });
79
+ }
80
+ if (window.__fcRevalidateCss) window.__fcRevalidateCss();
48
81
  })
49
82
  .fail(function (xhr) {
50
83
  console.error("[FC-Monaco] css-classes FAILED:", xhr.status, xhr.statusText);
@@ -102,102 +135,398 @@
102
135
  console.log(PREFIX, "addExtraLib globals.d.ts");
103
136
  jsDef.addExtraLib(libContent, "file:///globals.d.ts");
104
137
 
105
- // className completion provider — classes from the CSS engine in force
106
- // Triggers in: className="...", className={`...`}, and any string literal
107
- // containing at least one known TW class (e.g. "bg-red-500 text-white")
108
- console.log(PREFIX, "registering Tailwind completion provider");
109
- monaco.languages.registerCompletionItemProvider("javascript", {
110
- triggerCharacters: ['"', "'", "`", " "],
111
- provideCompletionItems: function (model, position) {
112
- const line = model.getLineContent(position.lineNumber);
113
- const before = line.substring(0, position.column - 1);
138
+ // className completion, hover and CSS preview — Tailwind CSS
139
+ // IntelliSense's logic (editor/class-completion.js) over the data of the
140
+ // CSS engine in force. Inside className="…" / className={…} it offers
141
+ // what IntelliSense offers: variants first (`hover:` reopens the list),
142
+ // then every class in the engine's order with colour swatches, and after
143
+ // `/` the class's modifiers. Strings elsewhere complete too once they
144
+ // hold a known class — this editor's addition.
145
+ console.log(PREFIX, "registering CSS class completion provider");
146
+ const cssKinds = {
147
+ variant: monaco.languages.CompletionItemKind.Module,
148
+ color: monaco.languages.CompletionItemKind.Color,
149
+ constant: monaco.languages.CompletionItemKind.Constant,
150
+ };
151
+
152
+ function cssData() {
153
+ const cc = window.__fcClassCompletion;
154
+ const data = window.__fcCssData;
155
+ if (!cc || !data || !data.classes.length) return null;
156
+ return cc.prepare(data);
157
+ }
114
158
 
115
- // 1) className="..." or className={`...`}
116
- const isClassName =
117
- before.match(/className\s*=\s*["'][^"']*$/) ||
118
- before.match(/className\s*=\s*\{`[^`]*$/);
159
+ // What the engine says about each candidate (CSS, summary, colour,
160
+ // conflict rules), or null — one request per batch, answers kept for
161
+ // the data's lifetime. Fast: hovers and the detail pane wait for it.
162
+ function describeCss(data, candidates) {
163
+ return askCssEngine(data, data.describe, "css-describe", data._described, candidates, 5000);
164
+ }
119
165
 
120
- // 2) Any open string literal: "..., '..., or `...
121
- const inString = !isClassName && before.match(/["'`][^"'`]*$/);
166
+ // Canonical spellings, for the diagnostic only: the engine may take its
167
+ // time (the Tailwind add-on's worker builds an index on its first
168
+ // answer), so nothing else waits for these.
169
+ function canonicalCss(data, candidates) {
170
+ return askCssEngine(data, data.canonical, "css-canonical", data._canonical, candidates, 30000);
171
+ }
122
172
 
123
- if (!isClassName && !inString) return { suggestions: [] };
173
+ const cssInflight = new WeakMap(); // answer cache → candidate → pending request
124
174
 
125
- // Extract text inside the string
126
- const raw = (isClassName || inString)[0];
127
- let inside;
128
- if (isClassName) {
129
- inside = raw.replace(/^className\s*=\s*(?:\{`|["'])/, "");
130
- } else {
131
- inside = raw.substring(1); // skip opening quote
175
+ function askCssEngine(data, supported, route, cache, candidates, timeout) {
176
+ const cc = window.__fcClassCompletion;
177
+ const nulls = candidates.map(function () {
178
+ return null;
179
+ });
180
+ if (!supported) return Promise.resolve(nulls);
181
+ // Classes already asked about and not answered yet are waited for,
182
+ // not asked again — typing while the first answer is slow would
183
+ // otherwise queue the same classes over and over.
184
+ let inflight = cssInflight.get(cache);
185
+ if (!inflight) cssInflight.set(cache, (inflight = new Map()));
186
+ const waiting = [];
187
+ const missing = candidates.filter(function (c, i) {
188
+ if (cache.has(c) || candidates.indexOf(c) !== i) return false;
189
+ if (inflight.has(c)) {
190
+ waiting.push(inflight.get(c));
191
+ return false;
132
192
  }
133
- inside = inside.replace(/\$\{[^}]*\}/g, " ");
134
- const parts = inside.split(/\s+/);
135
- const word = parts[parts.length - 1] || "";
136
-
137
- // For non-className strings, only activate if at least one existing
138
- // token in the string looks like a TW class (avoid noise in random strings)
139
- if (!isClassName && parts.length > 0) {
140
- const classes = window.__fcCssClasses || [];
141
- let twSet = window.__fcCssClassSet;
142
- if (!twSet && classes.length) {
143
- twSet = new Set(classes);
144
- window.__fcCssClassSet = twSet;
145
- console.log(PREFIX, "CSS: built class lookup Set, size=" + twSet.size);
146
- }
147
- if (twSet) {
148
- let hasTwToken = false;
149
- for (let p = 0; p < parts.length; p++) {
150
- if (parts[p] && twSet.has(parts[p])) {
151
- hasTwToken = true;
152
- break;
153
- }
154
- }
155
- // If typing the very first word, accept if it looks like a prefix
156
- if (!hasTwToken && parts.length === 1 && word.length >= 2) {
157
- for (let j = 0; j < classes.length; j++) {
158
- if (classes[j].indexOf(word) === 0) {
159
- hasTwToken = true;
160
- break;
161
- }
193
+ return true;
194
+ });
195
+ const batches = [];
196
+ for (let i = 0; i < missing.length; i += cc.MAX_DESCRIBE) {
197
+ batches.push(missing.slice(i, i + cc.MAX_DESCRIBE));
198
+ }
199
+ const asked = batches.map(function (batch) {
200
+ const request = new Promise(function (resolve) {
201
+ $.ajax({
202
+ url: "fromcubes-react/" + route,
203
+ data: { c: batch.join("\n") },
204
+ dataType: "json",
205
+ timeout: timeout,
206
+ })
207
+ .done(function (res) {
208
+ if (res && Array.isArray(res.results) && res.engine === data.engine) {
209
+ batch.forEach(function (c, i) {
210
+ cache.set(c, res.results[i] || null);
211
+ });
162
212
  }
163
- }
164
- if (!hasTwToken) return { suggestions: [] };
165
- }
166
- }
213
+ })
214
+ .fail(function (xhr) {
215
+ console.error(PREFIX, route + " FAILED:", xhr.status, xhr.statusText);
216
+ })
217
+ .always(function () {
218
+ batch.forEach(function (c) {
219
+ inflight.delete(c);
220
+ });
221
+ resolve();
222
+ });
223
+ });
224
+ batch.forEach(function (c) {
225
+ inflight.set(c, request);
226
+ });
227
+ return request;
228
+ });
229
+ return Promise.all(asked.concat(waiting)).then(function () {
230
+ return candidates.map(function (c) {
231
+ return cache.has(c) ? cache.get(c) : null;
232
+ });
233
+ });
234
+ }
167
235
 
168
- const range = {
169
- startLineNumber: position.lineNumber,
236
+ // A variant the data does not list by name (`group-hover/item`,
237
+ // `data-[open]`) is valid when the engine compiles it — IntelliSense's
238
+ // test, `<part>:[color:red]`.
239
+ function isValidVariant(data, part) {
240
+ if (!window.__fcClassCompletion.mayBeVariant(data, part)) return false;
241
+ return describeCss(data, [part + ":[color:red]"]).then(function (r) {
242
+ return !!r[0];
243
+ });
244
+ }
245
+
246
+ // Text the class-list scan needs: up to SEARCH_RANGE before the cursor,
247
+ // and the cursor's line split at the cursor.
248
+ function cssScanText(model, position) {
249
+ const cc = window.__fcClassCompletion;
250
+ const offset = model.getOffsetAt(position);
251
+ const from = model.getPositionAt(Math.max(0, offset - cc.SEARCH_RANGE));
252
+ const line = model.getLineContent(position.lineNumber);
253
+ return {
254
+ before: model.getValueInRange({
255
+ startLineNumber: from.lineNumber,
256
+ startColumn: from.column,
170
257
  endLineNumber: position.lineNumber,
171
- startColumn: position.column - word.length,
172
258
  endColumn: position.column,
173
- };
259
+ }),
260
+ lineBefore: line.substring(0, position.column - 1),
261
+ lineAfter: line.substring(position.column - 1),
262
+ };
263
+ }
174
264
 
175
- const classes = window.__fcCssClasses || [];
176
- const suggestions = [];
177
- for (let i = 0; i < classes.length; i++) {
178
- const cls = classes[i];
179
- if (!word.length || cls.indexOf(word) === 0) {
180
- suggestions.push({
181
- label: cls,
182
- kind: monaco.languages.CompletionItemKind.Value,
183
- insertText: cls,
184
- range: range,
185
- sortText: cls.padStart(60, "0"),
265
+ function cssFence(css) {
266
+ return { value: "```css\n" + css + "\n```" };
267
+ }
268
+
269
+ monaco.languages.registerCompletionItemProvider("javascript", {
270
+ triggerCharacters: window.__fcClassCompletion
271
+ ? window.__fcClassCompletion.TRIGGER_CHARACTERS
272
+ : ['"', "'", "`", " ", ".", "(", "[", "]", "!", "/", "-", ":"],
273
+ provideCompletionItems: function (model, position, context) {
274
+ const data = cssData();
275
+ if (!data) return { suggestions: [] };
276
+ const cc = window.__fcClassCompletion;
277
+ const text = cssScanText(model, position);
278
+ const at = cc.classListAt(data, text.before, text.lineBefore);
279
+ if (!at) return { suggestions: [] };
280
+ const kinds = monaco.languages.CompletionTriggerKind;
281
+ const ctx = {
282
+ triggerKind:
283
+ context.triggerKind === kinds.Invoke
284
+ ? "invoke"
285
+ : context.triggerKind === kinds.TriggerCharacter
286
+ ? "character"
287
+ : "incomplete",
288
+ triggerCharacter: context.triggerCharacter,
289
+ };
290
+ return cc
291
+ .completionsFromClassList(data, at.classList, ctx, function (part) {
292
+ return isValidVariant(data, part);
293
+ })
294
+ .then(function (list) {
295
+ if (!list.modifiers) return list;
296
+ return describeCss(
297
+ data,
298
+ list.items.map(function (item) {
299
+ return item.label;
300
+ }),
301
+ ).then(function (described) {
302
+ cc.applyModifierColors(list.items, described);
303
+ return list;
304
+ });
305
+ })
306
+ .then(function (list) {
307
+ const range = {
308
+ startLineNumber: position.lineNumber,
309
+ endLineNumber: position.lineNumber,
310
+ startColumn: position.column + list.range.start,
311
+ endColumn: position.column + list.range.end,
312
+ };
313
+ const owner = { variants: list.variants, important: list.important };
314
+ const suggestions = list.items.map(function (item) {
315
+ const s = {
316
+ label: item.label,
317
+ kind: cssKinds[item.kind],
318
+ insertText: item.insertText || item.label,
319
+ sortText: item.sortText,
320
+ range: range,
321
+ };
322
+ if (item.filterText) s.filterText = item.filterText;
323
+ if (item.detail) s.detail = item.detail;
324
+ if (item.documentation) s.documentation = item.documentation;
325
+ if (item.snippet) {
326
+ s.insertTextRules = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet;
327
+ }
328
+ if (item.retrigger) s.command = { id: "editor.action.triggerSuggest", title: "" };
329
+ if (item.kind !== "variant") s.fcClassOwner = owner;
330
+ return s;
186
331
  });
332
+ console.log(
333
+ PREFIX,
334
+ "CSS class completion: ctx=" +
335
+ (at.attribute ? "attribute" : "string") +
336
+ " items=" +
337
+ suggestions.length,
338
+ );
339
+ return { suggestions: suggestions };
340
+ });
341
+ },
342
+ // The detail pane: the declarations on one line, and the CSS the
343
+ // class generates under the variants typed before it.
344
+ resolveCompletionItem: function (item) {
345
+ const data = cssData();
346
+ if (!data || !item.fcClassOwner || (item.detail && item.documentation)) return item;
347
+ const className = item.fcClassOwner.important ? "!" + item.label : item.label;
348
+ const full = item.fcClassOwner.variants.concat([className]).join(":");
349
+ return describeCss(data, [full, className]).then(function (r) {
350
+ const whole = r[0];
351
+ const base = r[1];
352
+ if (!whole) return item;
353
+ if (!item.detail) {
354
+ item.detail = /^\d+ rules$/.test(whole.detail || "") ? whole.detail : (base || whole).detail || undefined;
187
355
  }
188
- }
189
- console.log(
190
- PREFIX,
191
- "CSS class completion: ctx=" +
192
- (isClassName ? "className" : "string") +
193
- " word='" +
194
- word +
195
- "', matched=" +
196
- suggestions.length +
197
- "/" +
198
- classes.length,
199
- );
200
- return { suggestions: suggestions };
356
+ if (!item.documentation && whole.documentation) item.documentation = cssFence(whole.documentation);
357
+ return item;
358
+ });
359
+ },
360
+ });
361
+
362
+ monaco.languages.registerHoverProvider("javascript", {
363
+ provideHover: function (model, position) {
364
+ const data = cssData();
365
+ if (!data || !data.describe) return null;
366
+ const text = cssScanText(model, position);
367
+ const hit = window.__fcClassCompletion.classNameAt(data, text.before, text.lineBefore, text.lineAfter);
368
+ if (!hit) return null;
369
+ return describeCss(data, [hit.className]).then(function (r) {
370
+ if (!r[0] || !r[0].hover) return null;
371
+ return {
372
+ range: {
373
+ startLineNumber: position.lineNumber,
374
+ endLineNumber: position.lineNumber,
375
+ startColumn: position.column + hit.start,
376
+ endColumn: position.column + hit.end,
377
+ },
378
+ contents: [cssFence(r[0].hover)],
379
+ };
380
+ });
381
+ },
382
+ });
383
+
384
+ // Diagnostics, quick fixes and colour squares — IntelliSense's
385
+ // cssConflict and suggestCanonicalClasses warnings and its document
386
+ // colours, over every class attribute of every javascript model (node
387
+ // dialogs and the React Editor alike), rechecked as the code changes.
388
+ function cssRange(model, start, end) {
389
+ const a = model.getPositionAt(start);
390
+ const b = model.getPositionAt(end);
391
+ return { startLineNumber: a.lineNumber, startColumn: a.column, endLineNumber: b.lineNumber, endColumn: b.column };
392
+ }
393
+
394
+ function cssClassLists(model) {
395
+ const cc = window.__fcClassCompletion;
396
+ return cc.findClassListsInText(cc.jsWithoutComments(model.getValue()));
397
+ }
398
+
399
+ const CSS_MARKER_OWNER = "tailwindcss";
400
+ const cssDiagnostics = new WeakMap(); // model → diagnostics, for their quick fixes
401
+ const cssTimers = new WeakMap();
402
+
403
+ function validateCss(model) {
404
+ if (model.isDisposed() || model.getLanguageId() !== "javascript") return;
405
+ const data = cssData();
406
+ if (!data || !data.describe) {
407
+ cssDiagnostics.delete(model);
408
+ monaco.editor.setModelMarkers(model, CSS_MARKER_OWNER, []);
409
+ return;
410
+ }
411
+ const cc = window.__fcClassCompletion;
412
+ const version = model.getVersionId();
413
+ const lists = cssClassLists(model);
414
+ const names = cc.uniqueClassNames(lists);
415
+ // Conflicts as soon as the classes are described; canonical
416
+ // suggestions join when the engine has them — a slow first answer
417
+ // never holds back the rest.
418
+ describeCss(data, names).then(function () {
419
+ publishCssMarkers(model, version, data, lists);
420
+ canonicalCss(data, names).then(function () {
421
+ publishCssMarkers(model, version, data, lists);
422
+ });
423
+ });
424
+ }
425
+
426
+ function publishCssMarkers(model, version, data, lists) {
427
+ if (model.isDisposed() || model.getVersionId() !== version || cssData() !== data) return;
428
+ const diagnostics = window.__fcClassCompletion.diagnose(lists, data._described, data._canonical);
429
+ cssDiagnostics.set(model, diagnostics);
430
+ monaco.editor.setModelMarkers(
431
+ model,
432
+ CSS_MARKER_OWNER,
433
+ diagnostics.map(function (d) {
434
+ return Object.assign(cssRange(model, d.start, d.end), {
435
+ severity: d.severity === "error" ? monaco.MarkerSeverity.Error : monaco.MarkerSeverity.Warning,
436
+ message: d.message,
437
+ source: "tailwindcss",
438
+ code: d.code,
439
+ relatedInformation: d.related.map(function (r) {
440
+ return Object.assign(cssRange(model, r.start, r.end), { resource: model.uri, message: r.message });
441
+ }),
442
+ });
443
+ }),
444
+ );
445
+ }
446
+
447
+ function scheduleCssValidation(model) {
448
+ clearTimeout(cssTimers.get(model));
449
+ cssTimers.set(
450
+ model,
451
+ setTimeout(function () {
452
+ validateCss(model);
453
+ }, 400),
454
+ );
455
+ }
456
+
457
+ function watchCssModel(model) {
458
+ const changed = model.onDidChangeContent(function () {
459
+ scheduleCssValidation(model);
460
+ });
461
+ const language = model.onDidChangeLanguage(function () {
462
+ scheduleCssValidation(model);
463
+ });
464
+ model.onWillDispose(function () {
465
+ changed.dispose();
466
+ language.dispose();
467
+ clearTimeout(cssTimers.get(model));
468
+ });
469
+ scheduleCssValidation(model);
470
+ }
471
+
472
+ monaco.editor.getModels().forEach(watchCssModel);
473
+ monaco.editor.onDidCreateModel(watchCssModel);
474
+ // New engine data (a dialog opened, the engine switched): check again.
475
+ window.__fcRevalidateCss = function () {
476
+ monaco.editor.getModels().forEach(scheduleCssValidation);
477
+ };
478
+
479
+ monaco.languages.registerCodeActionProvider("javascript", {
480
+ provideCodeActions: function (model, _range, context) {
481
+ const diagnostics = cssDiagnostics.get(model) || [];
482
+ const actions = [];
483
+ context.markers.forEach(function (marker) {
484
+ if (marker.source !== "tailwindcss") return;
485
+ const start = model.getOffsetAt({ lineNumber: marker.startLineNumber, column: marker.startColumn });
486
+ const end = model.getOffsetAt({ lineNumber: marker.endLineNumber, column: marker.endColumn });
487
+ diagnostics.forEach(function (d) {
488
+ if (d.start !== start || d.end !== end || d.code !== marker.code || d.message !== marker.message) return;
489
+ d.fixes.forEach(function (fix) {
490
+ actions.push({
491
+ title: fix.title,
492
+ kind: "quickfix",
493
+ diagnostics: [marker],
494
+ edit: {
495
+ edits: [
496
+ {
497
+ resource: model.uri,
498
+ versionId: model.getVersionId(),
499
+ textEdit: { range: cssRange(model, fix.start, fix.end), text: fix.text },
500
+ },
501
+ ],
502
+ },
503
+ });
504
+ });
505
+ });
506
+ });
507
+ return { actions: actions, dispose: function () {} };
508
+ },
509
+ });
510
+
511
+ monaco.languages.registerColorProvider("javascript", {
512
+ provideDocumentColors: function (model) {
513
+ const data = cssData();
514
+ if (!data) return [];
515
+ const cc = window.__fcClassCompletion;
516
+ const lists = cssClassLists(model);
517
+ return describeCss(data, cc.classesNeedingColor(data, lists)).then(function () {
518
+ if (model.isDisposed()) return [];
519
+ return cc.documentColors(data, lists, data._described).map(function (c) {
520
+ return { color: c.color, range: cssRange(model, c.start, c.end) };
521
+ });
522
+ });
523
+ },
524
+ // Only an arbitrary colour (`bg-[#f00]`) takes one back from the picker.
525
+ provideColorPresentations: function (model, info) {
526
+ const className = model.getValueInRange(info.range);
527
+ return window.__fcClassCompletion.colorPresentations(className, info.color).map(function (label) {
528
+ return { label: label, textEdit: { range: info.range, text: label } };
529
+ });
201
530
  },
202
531
  });
203
532
 
@@ -1041,10 +1370,11 @@
1041
1370
  return;
1042
1371
  }
1043
1372
  const s = document.createElement("script");
1044
- s.src = "fromcubes-react/vs/loader.js";
1373
+ const base = window.__fcAssetBase || "fromcubes-react/";
1374
+ s.src = base + "vs/loader.js";
1045
1375
  s.onload = function () {
1046
1376
  console.log(PREFIX, "loader.js loaded, configuring require paths");
1047
- require.config({ paths: { vs: "fromcubes-react/vs" } });
1377
+ require.config({ paths: { vs: base + "vs" } });
1048
1378
  require(["vs/editor/editor.main"], function () {
1049
1379
  console.log(PREFIX, "editor.main loaded");
1050
1380
  ensureJsxSetup();
@@ -1312,32 +1642,26 @@
1312
1642
  </script>
1313
1643
 
1314
1644
  <script type="text/html" data-help-name="fc-portal-component">
1645
+ <p>A React component every portal can use as a tag.</p>
1646
+
1647
+ <h3>Details</h3>
1315
1648
  <p>
1316
- Defines a shared JSX component available to all
1317
- <strong>portal-react</strong> nodes.
1649
+ Write the component in <b>JSX Code</b> and give its name in
1650
+ <b>JSX Tag</b>. After deploy, any <b>React Portal</b> uses it as
1651
+ <code>&lt;Name /&gt;</code>, without imports. Components can use each
1652
+ other and <code>useNodeRed()</code>.
1318
1653
  </p>
1654
+ <pre>function Badge({ label }) {
1655
+ return &lt;span className="badge"&gt;{label}&lt;/span&gt;;
1656
+ }</pre>
1657
+
1319
1658
  <h3>Properties</h3>
1320
1659
  <dl>
1321
1660
  <dt>JSX Tag</dt>
1322
- <dd>
1323
- The name used as a JSX tag in portal-react pages (e.g.
1324
- <code>&lt;MyComponent /&gt;</code>).
1325
- </dd>
1326
- <dt>Input fields</dt>
1327
- <dd>Comma-separated list of expected input props (documentation only).</dd>
1328
- <dt>Output fields</dt>
1329
- <dd>Comma-separated list of output props (documentation only).</dd>
1661
+ <dd>The component's name, starting with a capital letter (<code>Badge</code>).</dd>
1330
1662
  <dt>JSX Code</dt>
1331
- <dd>
1332
- The component function. Use <code>useNodeRed()</code> for data binding.
1333
- </dd>
1663
+ <dd>The component function.</dd>
1334
1664
  </dl>
1335
- <h3>Usage</h3>
1336
- <p>
1337
- Place this node on the canvas, set the JSX tag name and code, then deploy.
1338
- All <strong>portal-react</strong> nodes will automatically have access to
1339
- this component.
1340
- </p>
1341
1665
  </script>
1342
1666
 
1343
1667
  <!-- ============================================================
@@ -1558,57 +1882,28 @@
1558
1882
  </script>
1559
1883
 
1560
1884
  <script type="text/html" data-help-name="fc-portal-utility">
1885
+ <p>Shared JavaScript — helpers, hooks, constants — for every portal.</p>
1886
+
1887
+ <h3>Details</h3>
1561
1888
  <p>
1562
- Defines shared <strong>helpers</strong>, <strong>custom hooks</strong> and
1563
- <strong>constants</strong> available to all <strong>portal-react</strong>
1564
- nodes. Unlike <code>fc-portal-component</code>, the code is injected raw at
1565
- top level (no IIFE wrapper) — a single utility node may declare many symbols.
1889
+ Declare any number of functions, constants or classes in <b>Code</b>.
1890
+ Portals and components use them by name, without imports. A portal only
1891
+ includes this node if it uses something from it.
1566
1892
  </p>
1893
+ <pre>function clamp(n, min, max) {
1894
+ return Math.max(min, Math.min(max, n));
1895
+ }</pre>
1896
+
1567
1897
  <h3>Properties</h3>
1568
1898
  <dl>
1569
1899
  <dt>Module name</dt>
1570
1900
  <dd>
1571
- Identifier for this utility node. Shares a namespace with
1572
- <code>fc-portal-component</code> names — must be unique across all
1573
- components and utilities.
1901
+ A name for this node; it must not repeat a component's or another
1902
+ utility's name.
1574
1903
  </dd>
1575
1904
  <dt>Code</dt>
1576
- <dd>
1577
- Top-level JavaScript: <code>const</code>, <code>let</code>,
1578
- <code>function</code>, <code>class</code> declarations. <code>import</code>
1579
- statements at the top are auto-hoisted into the bundle.
1580
- </dd>
1905
+ <dd>Top-level declarations; <code>import</code> lines are allowed.</dd>
1581
1906
  </dl>
1582
- <h3>Selective inclusion</h3>
1583
- <p>
1584
- A utility node is bundled into a portal only when the portal's JSX (or any
1585
- of its referenced library components) mentions at least one of the symbols
1586
- declared in the utility. Unused utility nodes are skipped.
1587
- </p>
1588
- <p>
1589
- Group related symbols in one node — referencing any one symbol pulls in the
1590
- whole node's code. Split unrelated helpers across multiple nodes for finer
1591
- granularity.
1592
- </p>
1593
- <h3>Example</h3>
1594
- <pre>function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
1595
-
1596
- function useDebounce(value, ms = 300) {
1597
- const [v, setV] = React.useState(value);
1598
- React.useEffect(() => {
1599
- const t = setTimeout(() => setV(value), ms);
1600
- return () => clearTimeout(t);
1601
- }, [value, ms]);
1602
- return v;
1603
- }</pre>
1604
- <p>
1605
- Then in any <strong>portal-react</strong> node:
1606
- </p>
1607
- <pre>function App() {
1608
- const { data } = useNodeRed();
1609
- const slow = useDebounce(data?.value);
1610
- return &lt;div&gt;{clamp(slow ?? 0, 0, 100)}&lt;/div&gt;;
1611
- }</pre>
1612
1907
  </script>
1613
1908
 
1614
1909
  <!-- ============================================================
@@ -2415,125 +2710,86 @@ function useDebounce(value, ms = 300) {
2415
2710
  Help
2416
2711
  ============================================================ -->
2417
2712
  <script type="text/html" data-help-name="portal-react">
2418
- <h3>Quick Reference</h3>
2419
- <ul>
2420
- <li><code>useNodeRed()</code> &rarr; <code>{ data, send, user, portalClient }</code></li>
2421
- <li>Components from <code>fc-portal-component</code> nodes are auto-imported</li>
2422
- <li>Must export <code>&lt;App /&gt;</code></li>
2423
- <li>JSX is transpiled server-side at deploy</li>
2424
- </ul>
2425
-
2426
2713
  <p>
2427
- Renders a React 19 application on a configurable HTTP endpoint with live
2428
- WebSocket data binding. JSX is transpiled <strong>server-side at deploy
2429
- time</strong> using esbuild — browsers receive pre-compiled JS with zero
2430
- runtime compilation. Tailwind CSS 4 utility classes are generated
2431
- server-side and served as static CSS.
2714
+ A React page served at <code>/fromcubes/&lt;sub-path&gt;</code>, kept live
2715
+ with data from the flow.
2432
2716
  </p>
2433
2717
 
2434
2718
  <h3>Inputs</h3>
2435
- <p>
2436
- <code>msg.payload</code> is pushed via WebSocket. Set <code>msg._client</code>
2437
- to target specific clients, or omit it to broadcast to all.
2438
- </p>
2439
- <pre>
2440
- const { data, send, user, portalClient } = useNodeRed();
2441
- // data = last msg.payload (reactive)
2442
- // send(payload, topic?) — emit msg on output wire
2443
- // user = portal auth data or null
2444
- // portalClient = unique session/tab ID (assigned by server)</pre
2445
- >
2719
+ <dl class="message-properties">
2720
+ <dt>payload <span class="property-type">any</span></dt>
2721
+ <dd>Sent to the open pages; read it with <code>useNodeRed().data</code>.</dd>
2722
+ <dt class="optional">_client <span class="property-type">object</span></dt>
2723
+ <dd>
2724
+ Who gets it: <code>{ portalClient }</code> one browser tab,
2725
+ <code>{ userId }</code> every tab of one user,
2726
+ <code>{ authenticated: true }</code> every logged-in visitor.
2727
+ Without it, every open page.
2728
+ </dd>
2729
+ </dl>
2446
2730
 
2447
2731
  <h3>Outputs</h3>
2448
- <p>
2449
- <code>send(payload, topic?)</code> emits a <code>msg</code> on the node's
2450
- output wire via WebSocket.
2451
- </p>
2452
-
2453
- <h3>npm Packages</h3>
2454
- <p>
2455
- Add npm packages in the <strong>Modules</strong> list (e.g.
2456
- <code>chart.js/auto@^4.4.0</code>, <code>d3</code>, <code>three</code>).
2457
- Node-RED auto-installs them at deploy time. All packages are bundled with
2458
- React into a single vendor IIFE via esbuild, cached by hash of installed
2459
- versions. Use them in JSX via standard imports.
2460
- </p>
2732
+ <dl class="message-properties">
2733
+ <dt>payload <span class="property-type">any</span></dt>
2734
+ <dd>What the page passed to <code>send(payload, topic)</code>.</dd>
2735
+ <dt>_client <span class="property-type">object</span></dt>
2736
+ <dd>The tab and user it came from — send a reply back with it.</dd>
2737
+ </dl>
2461
2738
 
2462
- <h3>Deploy behavior</h3>
2463
- <ul>
2464
- <li>
2465
- Each deploy re-transpiles JSX via esbuild and rebuilds Tailwind CSS
2466
- (both cached by content hash — unchanged code is instant)
2467
- </li>
2468
- <li>
2469
- Vendor bundle (React + npm packages) rebuilt only when package versions
2470
- change
2471
- </li>
2472
- <li>
2473
- Active WebSocket clients receive close code 1001 and auto-reconnect
2474
- (exponential backoff)
2475
- </li>
2476
- <li>
2477
- Stale HTTP routes and WS handlers are cleaned up before new ones register
2478
- </li>
2479
- <li>
2480
- Transpile errors show in node status and as an error page on the endpoint
2481
- </li>
2482
- </ul>
2483
-
2484
- <h3>Shared Components</h3>
2739
+ <h3>Details</h3>
2485
2740
  <p>
2486
- Components defined in <strong>fc-portal-component</strong> nodes on the
2487
- canvas are auto-injected into every portal-react page. Use them as JSX tags
2488
- by their component name — no imports needed.
2741
+ Write a <code>function App()</code> in the <b>JSX</b> tab. It is compiled
2742
+ when you deploy, so the browser gets ready-made JavaScript.
2489
2743
  </p>
2490
-
2491
- <h3>Portal Auth</h3>
2744
+ <pre>function App() {
2745
+ const { data, send } = useNodeRed();
2746
+ return &lt;button onClick={() =&gt; send("hi")}&gt;{data}&lt;/button&gt;;
2747
+ }</pre>
2492
2748
  <p>
2493
- When enabled in the Auth tab, reads <code>X-Portal-*</code> headers set by
2494
- a reverse proxy (e.g. Nginx) and exposes user data:
2749
+ <code>useNodeRed()</code> gives <code>data</code> (the last payload,
2750
+ <code>null</code> until one arrives), <code>send</code>, <code>user</code>
2751
+ (the visitor, or <code>null</code>) and <code>portalClient</code> (this
2752
+ tab's id).
2495
2753
  </p>
2496
- <ul>
2497
- <li><code>useNodeRed()</code> returns <code>{ data, send, user, portalClient }</code></li>
2498
- <li>
2499
- All WebSocket messages include <code>msg._client</code> with
2500
- <code>{ portalClient, ...userFields }</code>
2501
- </li>
2502
- <li>
2503
- To target a specific tab: keep <code>msg._client</code> (or set
2504
- <code>msg._client = { portalClient: "..." }</code>)
2505
- </li>
2506
- <li>
2507
- To target all tabs of a user: set
2508
- <code>msg._client = { userId: "..." }</code> (no portalClient)
2509
- </li>
2510
- <li>
2511
- To target every authenticated session (skips anonymous):
2512
- <code>msg._client = { authenticated: true }</code>
2513
- </li>
2514
- <li>
2515
- To broadcast to all: remove <code>msg._client</code>. With
2516
- <b>Authenticated-only delivery</b> enabled this defaults to the
2517
- authenticated sessions instead; force a true broadcast with
2518
- <code>msg._client = { authenticated: false }</code>
2519
- </li>
2520
- </ul>
2521
-
2522
- <h3>Custom Head HTML</h3>
2523
2754
  <p>
2524
- Inject CDN links, fonts, or extra stylesheets into
2525
- <code>&lt;head&gt;</code>. This is raw trusted-author HTML; scripts and
2526
- event handlers run in the public portal page. Example:
2755
+ <b>React Component</b> and <b>React Utility</b> nodes can be used here by
2756
+ name, without imports.
2527
2757
  </p>
2528
- <pre>
2529
- &lt;link href="https://fonts.googleapis.com/css2?family=Inter&amp;display=swap" rel="stylesheet"&gt;</pre
2530
- >
2531
2758
 
2532
- <h3>WebSocket Status</h3>
2759
+ <h3>Properties</h3>
2760
+ <dl>
2761
+ <dt>Sub-path</dt>
2762
+ <dd>Where the page lives: <code>sensors</code> → <code>/fromcubes/sensors</code>.</dd>
2763
+ <dt>Title</dt>
2764
+ <dd>The browser tab's title.</dd>
2765
+ <dt>Modules</dt>
2766
+ <dd>
2767
+ npm packages to use in the code (<code>chart.js/auto</code>,
2768
+ <code>d3</code>, <code>three</code>); installed on deploy, then
2769
+ <code>import</code> them.
2770
+ </dd>
2771
+ <dt>Enable Portal Auth headers</dt>
2772
+ <dd>
2773
+ Identify visitors with the identity provider chosen in
2774
+ <b>Settings → Fromcubes</b>. Off: everyone is anonymous.
2775
+ </dd>
2776
+ <dt>Authenticated-only delivery</dt>
2777
+ <dd>
2778
+ Messages without <code>_client</code> go to logged-in visitors only;
2779
+ <code>_client = { authenticated: false }</code> still reaches everyone.
2780
+ </dd>
2781
+ <dt>Show WebSocket status indicator</dt>
2782
+ <dd>A small connected/disconnected badge in the page's corner.</dd>
2783
+ <dt>Head HTML</dt>
2784
+ <dd>Extra tags for the page's <code>&lt;head&gt;</code>, such as fonts or stylesheets.</dd>
2785
+ </dl>
2786
+
2787
+ <h3>Styling</h3>
2533
2788
  <p>
2534
- Optional <em>fromcubes</em> badge in the bottom-right corner showing
2535
- connection state. Enable via the <strong>Show WebSocket status
2536
- indicator</strong> checkbox (off by default).
2789
+ The page's stylesheet comes from the CSS engine chosen in
2790
+ <b>Settings → Fromcubes</b>. Install <code>@aaqu/fromcubes-tailwind</code>
2791
+ to style with Tailwind CSS classes; without a CSS engine the page has no
2792
+ generated stylesheet.
2537
2793
  </p>
2538
2794
  </script>
2539
2795