@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.
- package/README.md +11 -1
- package/THIRD-PARTY-NOTICES.md +27 -0
- package/nodes/editor/class-completion.js +1116 -0
- package/nodes/editor/file-tree.js +1 -1
- package/nodes/editor/format.js +1 -1
- package/nodes/editor/provider-settings.js +8 -8
- package/nodes/editor/workspace.js +5 -3
- package/nodes/lib/admin-api.js +64 -23
- package/nodes/lib/providers.js +272 -30
- package/nodes/portal-react.html +517 -261
- package/nodes/portal-react.js +6 -1
- package/package.json +1 -2
|
@@ -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
|
|
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) {
|
package/nodes/editor/format.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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: "
|
|
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", "
|
|
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", "
|
|
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);
|
package/nodes/lib/admin-api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 };
|
package/nodes/lib/providers.js
CHANGED
|
@@ -15,9 +15,19 @@
|
|
|
15
15
|
* type: "fromcubes-css",
|
|
16
16
|
* apiVersion: 1,
|
|
17
17
|
* generate(source) { return { css } },
|
|
18
|
-
* editor: {
|
|
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<
|
|
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
|
|
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
|
-
*
|
|
529
|
-
* force
|
|
530
|
-
*
|
|
531
|
-
*
|
|
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
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
710
|
+
raw = await editor[fn]();
|
|
544
711
|
} catch (e) {
|
|
545
|
-
RED.log.warn(`[portal-react] css engine "${engine}" editor
|
|
546
|
-
return
|
|
712
|
+
RED.log.warn(`[portal-react] css engine "${engine}" editor.${fn}() failed: ${e.message}`);
|
|
713
|
+
return empty();
|
|
547
714
|
}
|
|
548
|
-
const
|
|
549
|
-
if (Array.isArray(
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
};
|