@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.
@@ -7,7 +7,7 @@
7
7
  * module is a pair of pure functions so it can be unit-tested under vitest and
8
8
  * still be loaded as a plain browser script by the Node-RED editor.
9
9
  *
10
- * Loaded in the browser via `<script src="fromcubes-react/editor/file-tree.js">`
10
+ * Loaded in the browser by the asset loader in portal-react.html (`<base>/editor/file-tree.js`)
11
11
  * (exposes `window.__fcFileTree`) and in tests via `require()`.
12
12
  */
13
13
  (function (root, factory) {
@@ -13,7 +13,7 @@
13
13
  "use strict";
14
14
 
15
15
  const PREFIX = "[FC-Format]";
16
- const BASE = "fromcubes-react/prettier/";
16
+ const BASE = (window.__fcAssetBase || "fromcubes-react/") + "prettier/";
17
17
  // UMD builds: each sets window.prettier / window.prettierPlugins.
18
18
  const SCRIPTS = [
19
19
  "standalone.js",
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Settings → Fromcubes: choose the identity provider and the CSS engine.
2
+ * Settings → Fromcubes: choose the CSS engine and the identity provider.
3
3
  *
4
4
  * Exactly one provider per kind, shown as a radio list, so two can never be
5
5
  * active at once. Reads and writes `/fromcubes-react/providers`; the server
6
6
  * validates, persists to <userDir>/fromcubes/providers.json, and applies the
7
- * change (identity: every portal session reloads; css: every portal rebuilds).
7
+ * change (css: every portal rebuilds; identity: every portal session reloads).
8
8
  * A kind set in settings.js is shown locked.
9
9
  *
10
10
  * Node-RED shows a pane by the element id `red-ui-settings-tab-<pane id>`,
@@ -18,18 +18,18 @@
18
18
  var URL = "fromcubes-react/providers";
19
19
 
20
20
  var KINDS = [
21
- {
22
- kind: "identity",
23
- title: "Identity provider",
24
- hint: "Who is behind a portal session — used by Portal Auth, auth-cast and per-user delivery.",
25
- setting: "identityProvider",
26
- },
27
21
  {
28
22
  kind: "css",
29
23
  title: "CSS engine",
30
24
  hint: "Generates each portal's stylesheet at deploy. Changing it rebuilds every portal.",
31
25
  setting: "cssEngine",
32
26
  },
27
+ {
28
+ kind: "identity",
29
+ title: "Identity provider",
30
+ hint: "Who is behind a portal session — used by Portal Auth, auth-cast and per-user delivery.",
31
+ setting: "identityProvider",
32
+ },
33
33
  ];
34
34
 
35
35
  function errorText(xhr) {
@@ -19,6 +19,8 @@
19
19
  const EDITOR_NAME = "React Editor";
20
20
  const FLUSH_DEBOUNCE_MS = 400;
21
21
  const MIN_TREE_WIDTH = 150;
22
+ // Versioned asset path set by portal-react.html (see ASSET_BASE in lib/admin-api.js).
23
+ const ASSET_BASE = window.__fcAssetBase || "fromcubes-react/";
22
24
 
23
25
  // ── Module state ────────────────────────────────────────────
24
26
 
@@ -636,7 +638,7 @@
636
638
  * @returns {void}
637
639
  */
638
640
  function injectIconSprite() {
639
- $.ajax({ url: "fromcubes-react/editor/icons.svg", dataType: "text" })
641
+ $.ajax({ url: ASSET_BASE + "editor/icons.svg", dataType: "text" })
640
642
  .done(function (markup) {
641
643
  $("<div>")
642
644
  .attr("id", "fc-ws-icon-sprite")
@@ -1107,7 +1109,7 @@
1107
1109
  const brand = $("<div>").addClass("fc-ws-brand").appendTo(header);
1108
1110
  $("<img>")
1109
1111
  .addClass("fc-ws-mark-logo")
1110
- .attr("src", "fromcubes-react/editor/mark.svg")
1112
+ .attr("src", ASSET_BASE + "editor/mark.svg")
1111
1113
  .attr("alt", "fromcubes")
1112
1114
  .appendTo(brand);
1113
1115
  // One word split by colour — wrapped so the lockup's gap sits between the
@@ -1471,7 +1473,7 @@
1471
1473
  });
1472
1474
  $("<img>")
1473
1475
  .addClass("fc-ws-statusbar-icon")
1474
- .attr("src", "fromcubes-react/icons/fromcubes-react.svg")
1476
+ .attr("src", ASSET_BASE + "icons/fromcubes-react.svg")
1475
1477
  .attr("alt", "")
1476
1478
  .appendTo(el);
1477
1479
  $("<span>").text(EDITOR_NAME).appendTo(el);
@@ -1,5 +1,21 @@
1
1
  const path = require("path");
2
2
 
3
+ /**
4
+ * Editor assets are also served under a path carrying the core's version
5
+ * (`fromcubes-react/0.2.0/editor/…`), and the editor template loads them from
6
+ * there. Every cache in between — the browser, a CDN, a reverse proxy — keys
7
+ * on the path, so a new release can never be handed an old file, nor a 404
8
+ * remembered from a moment the route did not exist yet. Query strings are not
9
+ * enough: some caches ignore them. The unversioned paths stay for anything
10
+ * that still points at them.
11
+ */
12
+ const ASSET_VERSION = String(require("../../package.json").version).replace(
13
+ /[^0-9A-Za-z.+-]/g,
14
+ "",
15
+ );
16
+ /** Relative to the admin root, as the editor needs it (no leading slash). */
17
+ const ASSET_BASE = "fromcubes-react/" + ASSET_VERSION + "/";
18
+
3
19
  /**
4
20
  * Register editor/admin/public support routes for portal-react.
5
21
  *
@@ -37,41 +53,36 @@ function registerAdminApi(RED, deps) {
37
53
  onProviderChange,
38
54
  } = deps;
39
55
 
56
+ /**
57
+ * Mount one static directory at both its plain and its versioned path.
58
+ * @param {string} name
59
+ * @param {string} dir
60
+ * @returns {void}
61
+ */
62
+ function serveStatic(name, dir) {
63
+ const files = express.static(dir);
64
+ RED.httpAdmin.use("/fromcubes-react/" + name, permRead, files);
65
+ RED.httpAdmin.use("/" + ASSET_BASE + name, permRead, files);
66
+ }
67
+
40
68
  // Since 0.56 monaco-editor has an `exports` map that hides package.json;
41
69
  // its CommonJS entry is min/vs/index.js, so the AMD build (loader.js and
42
70
  // editor/editor.main.js) sits in the same directory.
43
- const monacoVsPath = path.dirname(require.resolve("monaco-editor"));
44
- RED.httpAdmin.use(
45
- "/fromcubes-react/vs",
46
- permRead,
47
- express.static(monacoVsPath),
48
- );
71
+ serveStatic("vs", path.dirname(require.resolve("monaco-editor")));
49
72
 
50
73
  // Editor-side workspace plugin (tree + full-screen Monaco overlay). Served
51
74
  // as static files instead of being inlined into portal-react.html so the
52
75
  // node's editor template stays readable.
53
- RED.httpAdmin.use(
54
- "/fromcubes-react/editor",
55
- permRead,
56
- express.static(path.join(__dirname, "..", "editor")),
57
- );
76
+ serveStatic("editor", path.join(__dirname, "..", "editor"));
58
77
 
59
78
  // The node icon doubles as the editor's status-bar mark. Serving the icons
60
79
  // directory here keeps one copy on disk and spares the client from having to
61
80
  // know the package name that Node-RED's own /icons route is keyed by.
62
- RED.httpAdmin.use(
63
- "/fromcubes-react/icons",
64
- permRead,
65
- express.static(path.join(__dirname, "..", "icons")),
66
- );
81
+ serveStatic("icons", path.join(__dirname, "..", "icons"));
67
82
 
68
83
  // Prettier's browser builds, straight from node_modules — same arrangement
69
84
  // as Monaco at /fromcubes-react/vs. Nothing is fetched until someone formats.
70
- RED.httpAdmin.use(
71
- "/fromcubes-react/prettier",
72
- permRead,
73
- express.static(path.dirname(require.resolve("prettier/package.json"))),
74
- );
85
+ serveStatic("prettier", path.dirname(require.resolve("prettier/package.json")));
75
86
 
76
87
  const CSS_HASH_RE = /^[a-f0-9]{1,64}$/;
77
88
  function findCssByHash(reqHash) {
@@ -193,7 +204,7 @@ function registerProviderRoutes(RED, deps) {
193
204
  res.json(providers.describe());
194
205
  });
195
206
 
196
- // Completion list for className="…", from whichever CSS engine is in force.
207
+ // Completion data for className="…", from whichever CSS engine is in force.
197
208
  // `engine` lets the editor tell a stale list from a fresh one.
198
209
  RED.httpAdmin.get("/fromcubes-react/css-classes", permRead, async (_req, res) => {
199
210
  try {
@@ -204,6 +215,36 @@ function registerProviderRoutes(RED, deps) {
204
215
  }
205
216
  });
206
217
 
218
+ // What the editor shows for classes it has in hand: a completion's CSS,
219
+ // a hover, a modifier's swatch, whether a variant exists. `c` carries the
220
+ // candidates newline-separated — a class never holds whitespace, and one
221
+ // string sidesteps query-string array parsing.
222
+ // Canonical spellings (`w-[16px]` → `w-4`) for the diagnostic — apart
223
+ // from css-describe because an engine may be slow here, and a hover must
224
+ // not wait for it. Same `c` format.
225
+ for (const [route, method, what] of [
226
+ ["css-describe", "describeClasses", "describe"],
227
+ ["css-canonical", "canonicalClasses", "canonicalise"],
228
+ ]) {
229
+ RED.httpAdmin.get("/fromcubes-react/" + route, permRead, async (req, res) => {
230
+ const c = req.query.c;
231
+ if (typeof c !== "string") {
232
+ res.status(400).json({ error: "expected class candidates in c" });
233
+ return;
234
+ }
235
+ try {
236
+ res.json(await providers[method](c.split("\n")));
237
+ } catch (e) {
238
+ if (e instanceof TypeError) {
239
+ res.status(400).json({ error: e.message });
240
+ return;
241
+ }
242
+ RED.log.error(`[portal-react] ${route}: ${e.message}`);
243
+ res.status(500).json({ error: `could not ${what} classes` });
244
+ }
245
+ });
246
+ }
247
+
207
248
  RED.httpAdmin.put(
208
249
  "/fromcubes-react/providers",
209
250
  permWrite,
@@ -235,4 +276,4 @@ function registerProviderRoutes(RED, deps) {
235
276
  );
236
277
  }
237
278
 
238
- module.exports = { registerAdminApi };
279
+ module.exports = { registerAdminApi, ASSET_BASE };
@@ -15,9 +15,19 @@
15
15
  * type: "fromcubes-css",
16
16
  * apiVersion: 1,
17
17
  * generate(source) { return { css } },
18
- * editor: { classes() { return ["p-4", …] } }, // optional: completion
18
+ * editor: { // optional, all three
19
+ * classes() { return ["p-4", …] }, // names only
20
+ * completions() { return { classes: [{ name, modifiers, color }], variants } },
21
+ * describe(candidates) { return [{ hover, documentation, detail, color, rules } | null, …] },
22
+ * canonical(candidates) { return ["w-4" | null, …] },
23
+ * },
19
24
  * })
20
25
  *
26
+ * `completions`, `describe` and `canonical` are what Tailwind CSS
27
+ * IntelliSense works from (modifiers, swatches, variants, CSS previews, colour squares in the
28
+ * code, conflict and canonical-class diagnostics); an engine with only
29
+ * `classes` still gets plain completion.
30
+ *
21
31
  * The core builds in only `none` for each kind. Everything that does real
22
32
  * work — `headers` (@aaqu/fromcubes-auth-headers) and
23
33
  * `tailwind` (@aaqu/fromcubes-tailwind) — is a Node-RED plugin like any third-party one,
@@ -69,6 +79,21 @@ const IDENTITY_TIMEOUT_MS = 5000;
69
79
  // every editor that opens a dialog.
70
80
  const MAX_EDITOR_CLASSES = 50000;
71
81
  const MAX_EDITOR_CLASS_LEN = 200;
82
+ const MAX_EDITOR_MODIFIERS = 1000;
83
+ const MAX_EDITOR_VARIANTS = 1000;
84
+ const MAX_EDITOR_DETAIL_LEN = 2000;
85
+ // One describe request: what a completion's detail pane, a hover or a
86
+ // modifier list needs at once, for candidates that may carry an arbitrary
87
+ // value — `w-[calc(100%-2rem)]` is longer than any listed class.
88
+ const MAX_DESCRIBE = 64;
89
+ const MAX_DESCRIBE_LEN = 1000;
90
+ const MAX_DESCRIBE_TEXT = 100000;
91
+ // Conflict-check entries per class: Tailwind's largest (shadows, with their
92
+ // @property registrations) hold about twenty.
93
+ const MAX_DESCRIBE_RULES = 200;
94
+ // Swatch colours: the forms Monaco draws (hex, rgb()/rgba() with commas)
95
+ // and the two keywords IntelliSense marks as colours without one.
96
+ const EDITOR_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|rgba?\([\d\s.,%]{1,60}\)|transparent|currentColor)$/;
72
97
 
73
98
  const USER_STRING_FIELDS = ["login", "displayName", "email", "role"];
74
99
 
@@ -138,6 +163,137 @@ function sanitizeUser(raw) {
138
163
  return user;
139
164
  }
140
165
 
166
+ /**
167
+ * @param {*} c
168
+ * @param {number} max
169
+ * @returns {boolean} a non-empty string without whitespace, at most `max` long
170
+ */
171
+ function isToken(c, max) {
172
+ return typeof c === "string" && c.length > 0 && c.length <= max && !/\s/.test(c);
173
+ }
174
+
175
+ /**
176
+ * @param {*} v
177
+ * @param {number} max
178
+ * @returns {string|null}
179
+ */
180
+ function boundedOrNull(v, max) {
181
+ return typeof v === "string" && v.length <= max ? v : null;
182
+ }
183
+
184
+ /**
185
+ * @param {*} c
186
+ * @returns {string|null}
187
+ */
188
+ function editorColor(c) {
189
+ return typeof c === "string" && EDITOR_COLOR_RE.test(c) ? c : null;
190
+ }
191
+
192
+ /**
193
+ * Validate a CSS engine's `editor.completions()` (or plain `classes()`)
194
+ * answer into what the editor is sent. Modifier lists are shared — Tailwind
195
+ * gives ~15 000 classes the same 21 opacity steps — so each distinct list
196
+ * travels once and classes point at it by index, as colours do by position.
197
+ *
198
+ * @param {*} rawClasses Array of names, or of `{name, modifiers?, color?}`.
199
+ * @param {*} [rawVariants]
200
+ * @returns {{classes: Array<string>, colors: Array<string>, modifiers: Array<Array<string>>, classModifiers: Array<number>, variants: Array<Object>}}
201
+ */
202
+ function normalizeCompletions(rawClasses, rawVariants) {
203
+ const classes = [];
204
+ const colors = [];
205
+ const modifiers = [];
206
+ const classModifiers = [];
207
+ const modifierIndex = new Map();
208
+ const seen = new Set();
209
+ for (const entry of Array.isArray(rawClasses) ? rawClasses : []) {
210
+ if (classes.length >= MAX_EDITOR_CLASSES) break;
211
+ const isObject = entry !== null && typeof entry === "object";
212
+ const name = isObject ? entry.name : entry;
213
+ if (!isToken(name, MAX_EDITOR_CLASS_LEN) || seen.has(name)) continue;
214
+ seen.add(name);
215
+ classes.push(name);
216
+ colors.push((isObject && editorColor(entry.color)) || "");
217
+ let mods = isObject && Array.isArray(entry.modifiers) ? entry.modifiers : [];
218
+ mods = mods.length <= MAX_EDITOR_MODIFIERS ? mods.filter((m) => isToken(m, MAX_EDITOR_CLASS_LEN)) : [];
219
+ if (mods.length === 0) {
220
+ classModifiers.push(-1);
221
+ continue;
222
+ }
223
+ const key = mods.join("\n");
224
+ let idx = modifierIndex.get(key);
225
+ if (idx === undefined) {
226
+ idx = modifiers.length;
227
+ modifiers.push(mods);
228
+ modifierIndex.set(key, idx);
229
+ }
230
+ classModifiers.push(idx);
231
+ }
232
+
233
+ const variants = [];
234
+ for (const v of Array.isArray(rawVariants) ? rawVariants : []) {
235
+ if (variants.length >= MAX_EDITOR_VARIANTS) break;
236
+ if (!v || typeof v !== "object" || !isToken(v.name, MAX_EDITOR_CLASS_LEN)) continue;
237
+ const values = Array.isArray(v.values) && v.values.length <= MAX_EDITOR_MODIFIERS ? v.values : [];
238
+ const valueDetails = Array.isArray(v.valueDetails) ? v.valueDetails : [];
239
+ const kept = [];
240
+ const details = [];
241
+ values.forEach((value, i) => {
242
+ if (!isToken(value, MAX_EDITOR_CLASS_LEN)) return;
243
+ kept.push(value);
244
+ details.push(boundedOrNull(valueDetails[i], MAX_EDITOR_DETAIL_LEN));
245
+ });
246
+ variants.push({
247
+ name: v.name,
248
+ values: kept,
249
+ hasDash: v.hasDash !== false,
250
+ isArbitrary: v.isArbitrary === true,
251
+ detail: boundedOrNull(v.detail, MAX_EDITOR_DETAIL_LEN),
252
+ valueDetails: details,
253
+ failed: v.failed === true,
254
+ });
255
+ }
256
+
257
+ return { classes, colors, modifiers, classModifiers, variants };
258
+ }
259
+
260
+ /**
261
+ * @param {*} list
262
+ * @param {number} maxItems
263
+ * @param {number} maxLen
264
+ * @returns {Array<string>|null} the list when it is short strings only
265
+ */
266
+ function boundedStrings(list, maxItems, maxLen) {
267
+ if (!Array.isArray(list) || list.length > maxItems) return null;
268
+ return list.every((v) => typeof v === "string" && v.length <= maxLen) ? list.slice() : null;
269
+ }
270
+
271
+ /**
272
+ * Validate one entry of a CSS engine's `editor.describe()` answer. `rules`
273
+ * (what the CSS-conflict diagnostic compares) is optional for an engine.
274
+ *
275
+ * @param {*} d
276
+ * @returns {{hover: string|null, documentation: string|null, detail: string|null, color: string|null, rules: Array<{properties: Array<string>, context: Array<string>}>}|null}
277
+ */
278
+ function sanitizeDescription(d) {
279
+ if (!d || typeof d !== "object") return null;
280
+ const rules = [];
281
+ if (Array.isArray(d.rules) && d.rules.length <= MAX_DESCRIBE_RULES) {
282
+ for (const r of d.rules) {
283
+ const properties = r && boundedStrings(r.properties, MAX_DESCRIBE_RULES, MAX_EDITOR_CLASS_LEN);
284
+ const context = r && boundedStrings(r.context, MAX_DESCRIBE_RULES, MAX_EDITOR_DETAIL_LEN);
285
+ if (properties && context) rules.push({ properties, context });
286
+ }
287
+ }
288
+ return {
289
+ hover: boundedOrNull(d.hover, MAX_DESCRIBE_TEXT),
290
+ documentation: boundedOrNull(d.documentation, MAX_DESCRIBE_TEXT),
291
+ detail: boundedOrNull(d.detail, MAX_DESCRIBE_TEXT),
292
+ color: editorColor(d.color),
293
+ rules,
294
+ };
295
+ }
296
+
141
297
  /**
142
298
  * The only providers built into the core: one per kind that does nothing, so
143
299
  * "no identity" and "no stylesheet" are always choosable. Their ids are
@@ -182,7 +338,9 @@ function builtinProviders() {
182
338
  * cssEngineId: function(): string,
183
339
  * describe: function(): Object,
184
340
  * select: function(string, string): boolean,
185
- * editorClasses: function(): Promise<{engine: string, classes: Array<string>}>,
341
+ * editorClasses: function(): Promise<Object>,
342
+ * describeClasses: function(Array<string>): Promise<{engine: string, results: Array<Object|null>}>,
343
+ * canonicalClasses: function(Array<string>): Promise<{engine: string, results: Array<string|null>}>,
186
344
  * }}
187
345
  */
188
346
  function createProviders(RED, opts = {}) {
@@ -195,8 +353,8 @@ function createProviders(RED, opts = {}) {
195
353
  const logged = new Set();
196
354
  /** @type {{identity?: string, css?: string}} */
197
355
  const chosen = loadChoice();
198
- // provider object → its validated class list. Keyed by the object, not the
199
- // id, so a plugin reinstalled at runtime (a new definition) is asked again.
356
+ // provider object → its validated completion data. Keyed by the object, not
357
+ // the id, so a plugin reinstalled at runtime (a new definition) is asked again.
200
358
  const classCache = new WeakMap();
201
359
 
202
360
  /**
@@ -525,44 +683,126 @@ function createProviders(RED, opts = {}) {
525
683
  }
526
684
 
527
685
  /**
528
- * Class names the editor offers inside `className="…"` for the engine in
529
- * force. Optional for plugins (`editor.classes()`); an engine without it,
530
- * an unusable engine, or one that fails yields an empty list — completion
531
- * just stays quiet, nothing else depends on it.
686
+ * What the editor completes inside `className="…"` for the engine in
687
+ * force: class names in the engine's order, with per class a swatch
688
+ * colour (`colors[i]`, "" for none) and modifier list (`modifiers[
689
+ * classModifiers[i]]`, -1 for none), plus the variants. Taken from the
690
+ * plugin's `editor.completions()`, or `editor.classes()` for names alone.
691
+ * Both are optional; an engine without them, an unusable engine, or one
692
+ * that fails yields an empty list — completion just stays quiet, nothing
693
+ * else depends on it. `describe` / `canonical` say whether
694
+ * `describeClasses` / `canonicalClasses` have anything to ask the engine.
532
695
  *
533
- * @returns {Promise<{engine: string, classes: Array<string>}>}
696
+ * @returns {Promise<{engine: string, classes: Array<string>, colors: Array<string>, modifiers: Array<Array<string>>, classModifiers: Array<number>, variants: Array<Object>, describe: boolean, canonical: boolean}>}
534
697
  */
535
698
  async function editorClasses() {
536
699
  const engine = cssEngineId();
537
700
  const { provider } = lookup(CSS_TYPE, builtins.css, engine);
538
- const fn = provider && provider.editor && provider.editor.classes;
539
- if (typeof fn !== "function") return { engine, classes: [] };
540
- if (classCache.has(provider)) return { engine, classes: classCache.get(provider) };
701
+ const editor = (provider && provider.editor) || {};
702
+ const describe = typeof editor.describe === "function";
703
+ const canonical = typeof editor.canonical === "function";
704
+ const empty = () => ({ engine, ...normalizeCompletions([]), describe, canonical });
705
+ const fn = typeof editor.completions === "function" ? "completions" : "classes";
706
+ if (typeof editor[fn] !== "function") return empty();
707
+ if (classCache.has(provider)) return { engine, ...classCache.get(provider), describe, canonical };
541
708
  let raw;
542
709
  try {
543
- raw = await fn.call(provider.editor);
710
+ raw = await editor[fn]();
544
711
  } catch (e) {
545
- RED.log.warn(`[portal-react] css engine "${engine}" editor.classes() failed: ${e.message}`);
546
- return { engine, classes: [] };
712
+ RED.log.warn(`[portal-react] css engine "${engine}" editor.${fn}() failed: ${e.message}`);
713
+ return empty();
547
714
  }
548
- const classes = [];
549
- if (Array.isArray(raw)) {
550
- const seen = new Set();
551
- for (const c of raw) {
552
- if (classes.length >= MAX_EDITOR_CLASSES) break;
553
- if (typeof c !== "string" || !c || c.length > MAX_EDITOR_CLASS_LEN || /\s/.test(c)) continue;
554
- if (seen.has(c)) continue;
555
- seen.add(c);
556
- classes.push(c);
557
- }
558
- } else {
559
- RED.log.warn(`[portal-react] css engine "${engine}" editor.classes() did not return an array`);
715
+ const rawClasses = fn === "completions" ? raw && raw.classes : raw;
716
+ if (!Array.isArray(rawClasses)) {
717
+ RED.log.warn(`[portal-react] css engine "${engine}" editor.${fn}() did not return ${fn === "completions" ? "a classes array" : "an array"}`);
718
+ }
719
+ const data = normalizeCompletions(rawClasses, fn === "completions" && raw ? raw.variants : []);
720
+ classCache.set(provider, data);
721
+ return { engine, ...data, describe, canonical };
722
+ }
723
+
724
+ /**
725
+ * What the editor shows for classes it has in hand — a completion's
726
+ * detail pane, a hover, a modifier's swatch, a colour square, a
727
+ * diagnostic — from the engine's `editor.describe()`: per candidate
728
+ * `{hover, documentation, detail, color, rules}`, or `null`
729
+ * when the engine generates nothing for it (which is
730
+ * also how the editor asks whether `group-hover/x` is a variant). Never
731
+ * rejects for an engine problem: no describe, or a failing one, answers
732
+ * `null` throughout.
733
+ *
734
+ * @param {*} candidates At most 64 class candidates.
735
+ * @returns {Promise<{engine: string, results: Array<Object|null>}>}
736
+ * @throws {TypeError} for a malformed list — a caller's error, not the engine's.
737
+ */
738
+ async function describeClasses(candidates) {
739
+ checkCandidates(candidates);
740
+ const engine = cssEngineId();
741
+ const { provider } = lookup(CSS_TYPE, builtins.css, engine);
742
+ const fn = provider && provider.editor && provider.editor.describe;
743
+ const nothing = { engine, results: candidates.map(() => null) };
744
+ if (typeof fn !== "function") return nothing;
745
+ let raw;
746
+ try {
747
+ raw = await fn.call(provider.editor, candidates.slice());
748
+ } catch (e) {
749
+ RED.log.warn(`[portal-react] css engine "${engine}" editor.describe() failed: ${e.message}`);
750
+ return nothing;
751
+ }
752
+ if (!Array.isArray(raw)) return nothing;
753
+ return { engine, results: candidates.map((_, i) => sanitizeDescription(raw[i])) };
754
+ }
755
+
756
+ /**
757
+ * The canonical spelling the engine suggests per class (`w-[16px]` →
758
+ * `w-4`), or `null` — for the canonical-class diagnostic only. Kept apart
759
+ * from `describeClasses` because an engine may take its time here (the
760
+ * Tailwind add-on asks a worker thread whose first answer waits for an
761
+ * index) and a hover must never wait for that. Never rejects for an
762
+ * engine problem.
763
+ *
764
+ * @param {*} candidates At most 64 class candidates.
765
+ * @returns {Promise<{engine: string, results: Array<string|null>}>}
766
+ * @throws {TypeError} for a malformed list.
767
+ */
768
+ async function canonicalClasses(candidates) {
769
+ checkCandidates(candidates);
770
+ const engine = cssEngineId();
771
+ const { provider } = lookup(CSS_TYPE, builtins.css, engine);
772
+ const fn = provider && provider.editor && provider.editor.canonical;
773
+ const nothing = { engine, results: candidates.map(() => null) };
774
+ if (typeof fn !== "function") return nothing;
775
+ let raw;
776
+ try {
777
+ raw = await fn.call(provider.editor, candidates.slice());
778
+ } catch (e) {
779
+ RED.log.warn(`[portal-react] css engine "${engine}" editor.canonical() failed: ${e.message}`);
780
+ return nothing;
781
+ }
782
+ if (!Array.isArray(raw)) return nothing;
783
+ return {
784
+ engine,
785
+ results: candidates.map((c, i) => (isToken(raw[i], MAX_DESCRIBE_LEN) && raw[i] !== c ? raw[i] : null)),
786
+ };
787
+ }
788
+
789
+ /**
790
+ * @param {*} candidates
791
+ * @returns {void}
792
+ * @throws {TypeError} unless 1–64 class candidates
793
+ */
794
+ function checkCandidates(candidates) {
795
+ if (
796
+ !Array.isArray(candidates) ||
797
+ candidates.length === 0 ||
798
+ candidates.length > MAX_DESCRIBE ||
799
+ !candidates.every((c) => isToken(c, MAX_DESCRIBE_LEN))
800
+ ) {
801
+ throw new TypeError(`expected 1-${MAX_DESCRIBE} class candidates`);
560
802
  }
561
- classCache.set(provider, classes);
562
- return { engine, classes };
563
803
  }
564
804
 
565
- return { resolveUser, generateCss, identityId, cssEngineId, describe, select, editorClasses };
805
+ return { resolveUser, generateCss, identityId, cssEngineId, describe, select, editorClasses, describeClasses, canonicalClasses };
566
806
  }
567
807
 
568
808
  module.exports = {
@@ -578,4 +818,6 @@ module.exports = {
578
818
  MAX_USER_STRING,
579
819
  MAX_GROUPS,
580
820
  MAX_EDITOR_CLASSES,
821
+ MAX_DESCRIBE,
822
+ normalizeCompletions,
581
823
  };