@bezda/rhp-mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +221 -2
- package/bin/rhp-mcp.js +125 -0
- package/package.json +62 -4
- package/skill/SKILL.md +363 -0
- package/skill/recipes/area.html +150 -0
- package/skill/recipes/bar.html +139 -0
- package/skill/recipes/box-plot.html +158 -0
- package/skill/recipes/bubble.html +182 -0
- package/skill/recipes/bullet.html +132 -0
- package/skill/recipes/candlestick.html +173 -0
- package/skill/recipes/column.html +125 -0
- package/skill/recipes/diverging-bars.html +144 -0
- package/skill/recipes/donut.html +225 -0
- package/skill/recipes/dumbbell.html +131 -0
- package/skill/recipes/gantt.html +186 -0
- package/skill/recipes/grouped-bars.html +136 -0
- package/skill/recipes/heatmap.html +177 -0
- package/skill/recipes/histogram.html +193 -0
- package/skill/recipes/line.html +164 -0
- package/skill/recipes/live.html +249 -0
- package/skill/recipes/lollipop.html +146 -0
- package/skill/recipes/multi-line.html +223 -0
- package/skill/recipes/pyramid.html +156 -0
- package/skill/recipes/race.html +272 -0
- package/skill/recipes/radial-bars.html +151 -0
- package/skill/recipes/scatter.html +193 -0
- package/skill/recipes/slope.html +193 -0
- package/skill/recipes/sparklines.html +169 -0
- package/skill/recipes/stacked-100.html +152 -0
- package/skill/recipes/stacked-bars.html +144 -0
- package/skill/recipes/strip.html +215 -0
- package/skill/recipes/violin.html +163 -0
- package/skill/recipes/waffle.html +159 -0
- package/skill/recipes/waterfall.html +121 -0
- package/skill/references/api.md +1556 -0
- package/skill/references/design.md +763 -0
- package/skill/references/environments.md +1570 -0
- package/skill/references/forms.md +160 -0
- package/skill/references/interaction.md +792 -0
- package/skill/references/pitfalls.md +157 -0
- package/src/check/README.md +163 -0
- package/src/check/browser.js +92 -0
- package/src/check/build.js +439 -0
- package/src/check/colors.js +324 -0
- package/src/check/findings.js +247 -0
- package/src/check/index.js +419 -0
- package/src/check/inpage.js +1268 -0
- package/src/check/interact.js +266 -0
- package/src/check/png.js +98 -0
- package/src/check/report.js +103 -0
- package/src/check/source.js +387 -0
- package/src/check/sourcemap.js +75 -0
- package/src/guard/note.js +30 -0
- package/src/guard/render.js +17 -0
- package/src/guard/rhp.js +608 -0
- package/src/guard/rules.js +125 -0
- package/src/server.js +265 -0
- package/src/skill.js +140 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// Static checks of a chart's code, for what rhp's html template and the standalone module get wrong with no error:
|
|
2
|
+
// bare-boolean <${Plot} keyboard> passes "", which is false
|
|
3
|
+
// handler-at-render onClick=${() => go()} on a component is read as a value: it runs at render and is never attached
|
|
4
|
+
// style-string style="..." on a Chart or a Plot is dropped (they take a style object)
|
|
5
|
+
// class-name className in a template: on a block it replaces rhp's class, and the block is not drawn
|
|
6
|
+
// two-solids @bezda/rhp/standalone imported beside solid-js: two copies of Solid, and reactivity breaks
|
|
7
|
+
// missing-export a name the standalone module doesn't export, with what to use instead
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { parseSync } from "@babel/core";
|
|
11
|
+
import { closest } from "../guard/rules.js";
|
|
12
|
+
|
|
13
|
+
const STANDALONE = "@bezda/rhp/standalone";
|
|
14
|
+
|
|
15
|
+
// The props of rhp's components (and Solid's Show) that take a boolean, and what "" (false) does to each
|
|
16
|
+
const BOOLEANS = { Chart: ["animate", "static"], Plot: ["overlap", "static", "keyboard", "animate"], Area: ["mirror", "smooth"], Line: ["fill", "smooth"], Show: ["keyed"] };
|
|
17
|
+
const OFF = {
|
|
18
|
+
overlap: "its slats don't share one band",
|
|
19
|
+
static: "the chart is not static",
|
|
20
|
+
keyboard: "its slats take no focus",
|
|
21
|
+
animate: "the JS version stays off",
|
|
22
|
+
mirror: "the Area is not mirrored",
|
|
23
|
+
smooth: "the outline stays straight",
|
|
24
|
+
fill: "nothing is filled under the Line",
|
|
25
|
+
keyed: "Show is not keyed",
|
|
26
|
+
};
|
|
27
|
+
const BLOCKS = new Set(["Bar", "Dot", "Tick", "Label", "Cell", "Place", "Area", "Line"]);
|
|
28
|
+
|
|
29
|
+
// What to use for what the standalone module doesn't have
|
|
30
|
+
const INSTEAD = {
|
|
31
|
+
createRenderEffect: "createEffect",
|
|
32
|
+
Dynamic: "a function that returns the template for the current choice",
|
|
33
|
+
Portal: "render() into the other element",
|
|
34
|
+
createResource: "fetch in onMount, and keep the result in a signal",
|
|
35
|
+
ErrorBoundary: "a check of the data before it is drawn (standalone has no error boundary)",
|
|
36
|
+
createContext: "values passed as props, or kept in a signal at the top of the module",
|
|
37
|
+
useContext: "values passed as props, or kept in a signal at the top of the module",
|
|
38
|
+
createUniqueId: "an id of your own (a counter)",
|
|
39
|
+
lazy: "a plain import",
|
|
40
|
+
Suspense: "Show, with a signal that says when the data is there",
|
|
41
|
+
children: "props.children, read inside a function",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// The names a module exports, from its last export statement (what esbuild writes at the end of a bundle)
|
|
45
|
+
const exportsOf = new Map();
|
|
46
|
+
function exported(file) {
|
|
47
|
+
|
|
48
|
+
if (!exportsOf.has(file)) {
|
|
49
|
+
const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
50
|
+
const list = text.match(/export\s*\{([^}]*)\}\s*;?\s*$/)?.[1];
|
|
51
|
+
exportsOf.set(file, list ? new Set(list.split(",").map((x) => x.trim().split(/\s+as\s+/).pop()).filter(Boolean)) : null);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return exportsOf.get(file);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Every node of a Babel AST, depth first
|
|
58
|
+
function walk(node, visit) {
|
|
59
|
+
|
|
60
|
+
if (!node || typeof node.type !== "string") return;
|
|
61
|
+
|
|
62
|
+
visit(node);
|
|
63
|
+
|
|
64
|
+
for (const key of Object.keys(node)) {
|
|
65
|
+
if (key === "loc" || key.endsWith("Comments") || key === "extra") continue;
|
|
66
|
+
const v = node[key];
|
|
67
|
+
if (Array.isArray(v)) {
|
|
68
|
+
for (const child of v) {
|
|
69
|
+
walk(child, visit);
|
|
70
|
+
}
|
|
71
|
+
} else if (v && typeof v.type === "string") {
|
|
72
|
+
walk(v, visit);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isSpace = (c) => c === " " || c === "\n" || c === "\t" || c === "\r" || c === "\f";
|
|
78
|
+
|
|
79
|
+
// The attributes in an html template, in order: { tag, name, value, at } with tag { component, expr, name }, value
|
|
80
|
+
// { kind: "bare" | "text" | "expr" | "mixed", text, node }, and at the attribute's offset in the source
|
|
81
|
+
function attributes(template) {
|
|
82
|
+
|
|
83
|
+
const { quasis, expressions } = template;
|
|
84
|
+
const out = [];
|
|
85
|
+
let state = "text";
|
|
86
|
+
let tag = null;
|
|
87
|
+
let attr = null;
|
|
88
|
+
let quote = "";
|
|
89
|
+
let tagName = "";
|
|
90
|
+
|
|
91
|
+
const begin = (name, at) => (attr = { tag, name, value: null, at, text: "", parts: [] });
|
|
92
|
+
const end = (value) => {
|
|
93
|
+
if (attr && attr.name !== "..." && attr.name) out.push({ tag: attr.tag, name: attr.name, at: attr.at, value: value ?? { kind: "bare" } });
|
|
94
|
+
attr = null;
|
|
95
|
+
};
|
|
96
|
+
const closeTag = () => {
|
|
97
|
+
tag = null;
|
|
98
|
+
state = "text";
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
for (let q = 0; q < quasis.length; q++) {
|
|
102
|
+
const raw = quasis[q].value.raw;
|
|
103
|
+
const base = quasis[q].start;
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < raw.length; i++) {
|
|
106
|
+
const c = raw[i];
|
|
107
|
+
|
|
108
|
+
if (state === "text") {
|
|
109
|
+
if (c !== "<") continue;
|
|
110
|
+
if (raw.startsWith("<!--", i)) state = "comment";
|
|
111
|
+
else if (raw[i + 1] === "/") state = "close";
|
|
112
|
+
else {
|
|
113
|
+
state = "open";
|
|
114
|
+
tagName = "";
|
|
115
|
+
}
|
|
116
|
+
} else if (state === "comment") {
|
|
117
|
+
if (raw.startsWith("-->", i)) {
|
|
118
|
+
state = "text";
|
|
119
|
+
i += 2;
|
|
120
|
+
}
|
|
121
|
+
} else if (state === "close") {
|
|
122
|
+
if (c === ">") state = "text";
|
|
123
|
+
} else if (state === "open") {
|
|
124
|
+
if (/[A-Za-z0-9-]/.test(c)) {
|
|
125
|
+
tagName += c;
|
|
126
|
+
} else {
|
|
127
|
+
if (!tagName) {
|
|
128
|
+
state = "text";
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
tag = { component: false, name: tagName.toLowerCase() };
|
|
132
|
+
state = "tag";
|
|
133
|
+
i--;
|
|
134
|
+
}
|
|
135
|
+
} else if (state === "tag") {
|
|
136
|
+
if (isSpace(c)) continue;
|
|
137
|
+
if (c === ">") closeTag();
|
|
138
|
+
else if (c === "/" && raw[i + 1] === ">") {
|
|
139
|
+
closeTag();
|
|
140
|
+
i++;
|
|
141
|
+
} else {
|
|
142
|
+
begin(c, base + i);
|
|
143
|
+
state = "name";
|
|
144
|
+
}
|
|
145
|
+
} else if (state === "name") {
|
|
146
|
+
if (c === "=") state = "equals";
|
|
147
|
+
else if (isSpace(c)) state = "named";
|
|
148
|
+
else if (c === ">" || (c === "/" && raw[i + 1] === ">")) {
|
|
149
|
+
end();
|
|
150
|
+
state = "tag";
|
|
151
|
+
i--;
|
|
152
|
+
} else {
|
|
153
|
+
attr.name += c;
|
|
154
|
+
}
|
|
155
|
+
} else if (state === "named") {
|
|
156
|
+
if (isSpace(c)) continue;
|
|
157
|
+
if (c === "=") state = "equals";
|
|
158
|
+
else {
|
|
159
|
+
end();
|
|
160
|
+
state = "tag";
|
|
161
|
+
i--;
|
|
162
|
+
}
|
|
163
|
+
} else if (state === "equals") {
|
|
164
|
+
if (isSpace(c)) continue;
|
|
165
|
+
if (c === '"' || c === "'") {
|
|
166
|
+
quote = c;
|
|
167
|
+
state = "quoted";
|
|
168
|
+
} else {
|
|
169
|
+
attr.text = c;
|
|
170
|
+
state = "unquoted";
|
|
171
|
+
}
|
|
172
|
+
} else if (state === "quoted") {
|
|
173
|
+
if (c === quote) {
|
|
174
|
+
end(attr.parts.length === 1 && !attr.text.trim() ? { kind: "expr", node: attr.parts[0] } : attr.parts.length ? { kind: "mixed" } : { kind: "text", text: attr.text });
|
|
175
|
+
state = "tag";
|
|
176
|
+
} else {
|
|
177
|
+
attr.text += c;
|
|
178
|
+
}
|
|
179
|
+
} else if (state === "unquoted") {
|
|
180
|
+
if (isSpace(c) || c === ">") {
|
|
181
|
+
end({ kind: "text", text: attr.text });
|
|
182
|
+
state = "tag";
|
|
183
|
+
i--;
|
|
184
|
+
} else {
|
|
185
|
+
attr.text += c;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (q === expressions.length) break;
|
|
191
|
+
const expr = expressions[q];
|
|
192
|
+
|
|
193
|
+
if (state === "open" && !tagName) {
|
|
194
|
+
tag = { component: true, expr };
|
|
195
|
+
state = "tag";
|
|
196
|
+
} else if (state === "open") {
|
|
197
|
+
tag = { component: false, name: tagName.toLowerCase() };
|
|
198
|
+
state = "tag";
|
|
199
|
+
} else if (state === "equals") {
|
|
200
|
+
end({ kind: "expr", node: expr });
|
|
201
|
+
state = "tag";
|
|
202
|
+
} else if (state === "quoted") {
|
|
203
|
+
attr.parts.push(expr);
|
|
204
|
+
} else if (state === "name" || state === "named") {
|
|
205
|
+
// a spread (...${props}) or a name made of an expression: nothing to check
|
|
206
|
+
attr = null;
|
|
207
|
+
state = "tag";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The checks of one module's code. offset is where the code starts in the file, for the line numbers.
|
|
215
|
+
function checkModule(code, { offset, lineAt, file, rhpRoot, jsx }) {
|
|
216
|
+
|
|
217
|
+
let ast;
|
|
218
|
+
try {
|
|
219
|
+
ast = parseSync(code, { sourceType: "module", configFile: false, babelrc: false, filename: file, parserOpts: { plugins: jsx ? ["jsx", ...(/\.tsx?$/.test(file) ? ["typescript"] : [])] : [], errorRecovery: true } });
|
|
220
|
+
} catch {
|
|
221
|
+
return { problems: [], imports: [] };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const problems = [];
|
|
225
|
+
const where = (at) => `${path.basename(file)}:${lineAt(offset + at)}`;
|
|
226
|
+
const imports = [];
|
|
227
|
+
const local = new Map(); // a local name -> { source, name } of what it imports
|
|
228
|
+
const params = new Map(); // a function's name -> its number of parameters (null when the name is bound twice)
|
|
229
|
+
const templates = [];
|
|
230
|
+
|
|
231
|
+
walk(ast.program, (node) => {
|
|
232
|
+
if (node.type === "ImportDeclaration") {
|
|
233
|
+
imports.push({ source: node.source.value, at: node.start, node });
|
|
234
|
+
for (const s of node.specifiers) {
|
|
235
|
+
const name = s.type === "ImportSpecifier" ? s.imported.name ?? s.imported.value : s.type === "ImportDefaultSpecifier" ? "default" : "*";
|
|
236
|
+
local.set(s.local.name, { source: node.source.value, name, at: s.start });
|
|
237
|
+
}
|
|
238
|
+
} else if (node.type === "ImportExpression" || (node.type === "CallExpression" && node.callee.type === "Import")) {
|
|
239
|
+
const arg = node.source ?? node.arguments?.[0];
|
|
240
|
+
if (arg?.type === "StringLiteral") imports.push({ source: arg.value, at: node.start });
|
|
241
|
+
} else if (node.type === "FunctionDeclaration" && node.id) {
|
|
242
|
+
params.set(node.id.name, params.has(node.id.name) ? null : node.params.length);
|
|
243
|
+
} else if (node.type === "VariableDeclarator" && node.id.type === "Identifier" && /^(Arrow)?FunctionExpression$/.test(node.init?.type ?? "")) {
|
|
244
|
+
params.set(node.id.name, params.has(node.id.name) ? null : node.init.params.length);
|
|
245
|
+
} else if (node.type === "TaggedTemplateExpression") {
|
|
246
|
+
templates.push(node);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// html is Solid's tagged template, from the standalone module or solid-js/html
|
|
251
|
+
const isHtml = (tag) => {
|
|
252
|
+
if (tag.type !== "Identifier") return false;
|
|
253
|
+
const from = local.get(tag.name);
|
|
254
|
+
if (!from) return tag.name === "html";
|
|
255
|
+
return (from.source === STANDALONE && from.name === "html") || (from.source === "solid-js/html" && from.name === "default");
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// A component's name as rhp names it: what an identifier imports, or the property of rhp.Plot
|
|
259
|
+
const componentName = (expr) => {
|
|
260
|
+
if (expr?.type === "Identifier") {
|
|
261
|
+
const from = local.get(expr.name);
|
|
262
|
+
return from && from.name !== "*" && from.name !== "default" ? from.name : expr.name;
|
|
263
|
+
}
|
|
264
|
+
if (expr?.type === "MemberExpression" && !expr.computed) return expr.property.name;
|
|
265
|
+
return null;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const noParameter = (node) => {
|
|
269
|
+
if (!node) return false;
|
|
270
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") return node.params.length === 0;
|
|
271
|
+
return node.type === "Identifier" && params.get(node.name) === 0;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
for (const t of templates) {
|
|
275
|
+
if (!isHtml(t.tag)) continue;
|
|
276
|
+
for (const a of attributes(t.quasi)) {
|
|
277
|
+
const comp = a.tag.component ? componentName(a.tag.expr) : null;
|
|
278
|
+
const shown = a.tag.component ? `<\${${comp ?? "Component"}}>` : `<${a.tag.name}>`;
|
|
279
|
+
|
|
280
|
+
if (a.name === "className") {
|
|
281
|
+
problems.push({
|
|
282
|
+
level: "error",
|
|
283
|
+
code: "class-name",
|
|
284
|
+
message: `${where(a.at)}: className on ${shown}: ${BLOCKS.has(comp) ? "on a block it replaces rhp's class, so the block is not drawn" : "the html template sets no class from it"}.`,
|
|
285
|
+
fix: "Use class, as in HTML: class=\"name\".",
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!a.tag.component) continue;
|
|
290
|
+
|
|
291
|
+
if (a.value.kind === "bare" && BOOLEANS[comp]?.includes(a.name)) {
|
|
292
|
+
problems.push({
|
|
293
|
+
level: "error",
|
|
294
|
+
code: "bare-boolean",
|
|
295
|
+
message: `${where(a.at)}: ${a.name} on ${shown} is written bare, which passes "" (false), so ${OFF[a.name]}.`,
|
|
296
|
+
fix: `Write ${a.name}=\${true}: in an html template a boolean is written out.`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (/^on(:|[A-Z])/.test(a.name) && a.value.kind === "expr" && noParameter(a.value.node)) {
|
|
301
|
+
problems.push({
|
|
302
|
+
level: "error",
|
|
303
|
+
code: "handler-at-render",
|
|
304
|
+
message: `${where(a.at)}: ${a.name} on ${shown} is a function with no parameter, which the html template reads as a value: it runs once when the chart is drawn, and is never attached.`,
|
|
305
|
+
fix: `Give the handler a parameter: ${a.name}=\${(e) => ...}${a.value.node.type === "Identifier" ? ` (or define ${a.value.node.name} with one: (e) => ...)` : ""}.`,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const string = a.value.kind === "text" || a.value.kind === "mixed" || (a.value.kind === "expr" && /^(StringLiteral|TemplateLiteral)$/.test(a.value.node.type));
|
|
310
|
+
if (a.name === "style" && (comp === "Chart" || comp === "Plot") && string) {
|
|
311
|
+
problems.push({
|
|
312
|
+
level: "error",
|
|
313
|
+
code: "style-string",
|
|
314
|
+
message: `${where(a.at)}: style on ${shown} is a string, and a ${comp} takes only a style object, so it is dropped.`,
|
|
315
|
+
fix: "Give an object of CSS properties: style=${{ \"pointer-events\": \"none\" }}.",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Names the standalone module doesn't have
|
|
322
|
+
const names = exported(path.join(rhpRoot, "dist/standalone.js"));
|
|
323
|
+
for (const [, from] of local) {
|
|
324
|
+
if (from.source !== STANDALONE || !names || from.name === "*" || from.name === "default" || names.has(from.name)) continue;
|
|
325
|
+
const near = closest(from.name, [...names]);
|
|
326
|
+
problems.push({
|
|
327
|
+
level: "error",
|
|
328
|
+
code: "missing-export",
|
|
329
|
+
name: from.name,
|
|
330
|
+
message: `${where(from.at)}: ${STANDALONE} has no export ${from.name}, so the module fails to load and nothing is drawn.`,
|
|
331
|
+
fix: INSTEAD[from.name] ? `Use ${INSTEAD[from.name]}.` : near ? `Did you mean ${near}?` : `It exports rhp and, from Solid: render, html, createSignal, createMemo, createEffect, createRoot, onMount, onCleanup, batch, untrack, For, Index, Show, createStore, reconcile, produce, unwrap.`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return { problems, imports: imports.map((x) => ({ source: x.source, where: where(x.at) })) };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// The static problems of a chart's file: an html page's module scripts, or a module or component file
|
|
339
|
+
export function sourceProblems({ file, text, format, rhpRoot }) {
|
|
340
|
+
|
|
341
|
+
const starts = [0];
|
|
342
|
+
for (let i = 0; i < text.length; i++) {
|
|
343
|
+
if (text[i] === "\n") starts.push(i + 1);
|
|
344
|
+
}
|
|
345
|
+
const lineAt = (offset) => {
|
|
346
|
+
let lo = 0;
|
|
347
|
+
let hi = starts.length - 1;
|
|
348
|
+
while (lo < hi) {
|
|
349
|
+
const mid = (lo + hi + 1) >> 1;
|
|
350
|
+
if (starts[mid] <= offset) lo = mid;
|
|
351
|
+
else hi = mid - 1;
|
|
352
|
+
}
|
|
353
|
+
return lo + 1;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const modules = [];
|
|
357
|
+
if (format === "html") {
|
|
358
|
+
for (const m of text.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)) {
|
|
359
|
+
if (/\bsrc\s*=/.test(m[1]) || !/type\s*=\s*["']?module/i.test(m[1])) continue;
|
|
360
|
+
modules.push({ code: m[2], offset: m.index + m[0].indexOf(">") + 1, jsx: false });
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
modules.push({ code: text, offset: 0, jsx: format === "solid" || format === "react" || /\.[jt]sx$/.test(file) });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const problems = [];
|
|
367
|
+
const imports = [];
|
|
368
|
+
for (const m of modules) {
|
|
369
|
+
const r = checkModule(m.code, { ...m, lineAt, file, rhpRoot });
|
|
370
|
+
problems.push(...r.problems);
|
|
371
|
+
imports.push(...r.imports);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Two copies of Solid: the standalone module carries its own
|
|
375
|
+
const standalone = imports.find((x) => x.source === STANDALONE);
|
|
376
|
+
const solid = imports.filter((x) => /^solid-js(\/|$)/.test(x.source));
|
|
377
|
+
if (standalone && solid.length) {
|
|
378
|
+
problems.push({
|
|
379
|
+
level: "error",
|
|
380
|
+
code: "two-solids",
|
|
381
|
+
message: `${solid[0].where}: the code imports ${[...new Set(solid.map((x) => x.source))].join(" and ")} beside ${STANDALONE}, which carries its own Solid: with two copies, a signal from one is not tracked by the other, and the chart silently stops following it.`,
|
|
382
|
+
fix: `Import everything from ${STANDALONE} (it has render, html, createSignal, createMemo, createEffect, Show, For, Index, createStore...), and drop solid-js.`,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return problems;
|
|
387
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Maps a place in a bundle back to the user's file, with the bundle's inline source map, so that an error thrown in
|
|
2
|
+
// a bundled chart says where it is in the chart's own code.
|
|
3
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
4
|
+
const DIGIT = Object.fromEntries([...B64].map((c, i) => [c, i]));
|
|
5
|
+
|
|
6
|
+
function decodeSegment(text) {
|
|
7
|
+
|
|
8
|
+
const out = [];
|
|
9
|
+
let value = 0;
|
|
10
|
+
let shift = 0;
|
|
11
|
+
|
|
12
|
+
for (const ch of text) {
|
|
13
|
+
const digit = DIGIT[ch];
|
|
14
|
+
value += (digit & 31) << shift;
|
|
15
|
+
if (digit & 32) {
|
|
16
|
+
shift += 5;
|
|
17
|
+
} else {
|
|
18
|
+
out.push(value & 1 ? -(value >> 1) : value >> 1);
|
|
19
|
+
value = 0;
|
|
20
|
+
shift = 0;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The source map in a bundle's text (its last sourceMappingURL comment, as a data URL), parsed into lines of segments
|
|
28
|
+
export function readMap(code) {
|
|
29
|
+
|
|
30
|
+
const m = code.match(/\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)\s*$/);
|
|
31
|
+
if (!m) return null;
|
|
32
|
+
|
|
33
|
+
const map = JSON.parse(Buffer.from(m[1], "base64").toString("utf8"));
|
|
34
|
+
const lines = [];
|
|
35
|
+
let source = 0;
|
|
36
|
+
let line = 0;
|
|
37
|
+
let column = 0;
|
|
38
|
+
|
|
39
|
+
for (const text of map.mappings.split(";")) {
|
|
40
|
+
const segments = [];
|
|
41
|
+
let generated = 0;
|
|
42
|
+
for (const part of text.split(",")) {
|
|
43
|
+
if (!part) continue;
|
|
44
|
+
const s = decodeSegment(part);
|
|
45
|
+
generated += s[0];
|
|
46
|
+
if (s.length >= 4) {
|
|
47
|
+
source += s[1];
|
|
48
|
+
line += s[2];
|
|
49
|
+
column += s[3];
|
|
50
|
+
segments.push([generated, source, line, column]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
lines.push(segments);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { sources: map.sources, lines };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The user's file, line and column (1-based) for a line and column (1-based) in the bundle, or null
|
|
60
|
+
export function lookup(map, line, column) {
|
|
61
|
+
|
|
62
|
+
const segments = map?.lines[line - 1];
|
|
63
|
+
if (!segments?.length) return null;
|
|
64
|
+
|
|
65
|
+
let best = null;
|
|
66
|
+
|
|
67
|
+
for (const s of segments) {
|
|
68
|
+
if (s[0] <= column - 1) best = s;
|
|
69
|
+
else break;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
best ??= segments[0];
|
|
73
|
+
|
|
74
|
+
return { source: map.sources[best[1]], line: best[2] + 1, column: best[3] + 1 };
|
|
75
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Where the guard puts what it finds: globalThis.__rhpCheck.findings, which the checker reads from the page.
|
|
2
|
+
export const store = (globalThis.__rhpCheck ??= { findings: [] });
|
|
3
|
+
const seen = new Set();
|
|
4
|
+
let checking = 0;
|
|
5
|
+
|
|
6
|
+
export function note(level, code, message, fix) {
|
|
7
|
+
|
|
8
|
+
const key = code + "\n" + message;
|
|
9
|
+
if (seen.has(key)) return;
|
|
10
|
+
|
|
11
|
+
seen.add(key);
|
|
12
|
+
store.findings.push({ level, code, message, fix, source: "guard" });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Whether a check is running: what the guard reads itself is not what the chart reads
|
|
16
|
+
export const busy = () => checking > 0;
|
|
17
|
+
|
|
18
|
+
// Runs a check, so that a check that fails on something unexpected never reaches the app
|
|
19
|
+
export function safely(f) {
|
|
20
|
+
|
|
21
|
+
checking++;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
f();
|
|
25
|
+
} catch {
|
|
26
|
+
// a check never breaks the chart
|
|
27
|
+
} finally {
|
|
28
|
+
checking--;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Solid's render, as @bezda/rhp/standalone exports it, checking what it is given first
|
|
2
|
+
import { render as solidRender } from "solid-js/web";
|
|
3
|
+
import { note, safely } from "./note.js";
|
|
4
|
+
|
|
5
|
+
export function render(code, element, ...rest) {
|
|
6
|
+
|
|
7
|
+
safely(() => {
|
|
8
|
+
if (typeof code !== "function") {
|
|
9
|
+
note("error", "bad-prop", "render's first argument is not a function.", "Give render a function that returns the chart: render(() => html`<${Chart} ...>`, element).");
|
|
10
|
+
}
|
|
11
|
+
if (element == null) {
|
|
12
|
+
note("error", "bad-prop", "render's element is missing (null): the page has no element with that id when the script runs.", "Give the page that element (<div id=\"chart\"></div>) before the module script, or fix the id in getElementById.");
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
return solidRender(code, element, ...rest);
|
|
17
|
+
}
|