@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,439 @@
|
|
|
1
|
+
// What the browser loads. An html page loads rhp from a CDN, and the checker answers with a guarded standalone module
|
|
2
|
+
// (built here once, and kept in the OS temp dir). A solid, react or module file is bundled here with the guard, rhp,
|
|
3
|
+
// Solid (and React) into one script, which a page the checker writes loads.
|
|
4
|
+
// One Solid: the user's code, rhp and the guard must share one copy of solid-js, or reactivity silently breaks. Every
|
|
5
|
+
// import of solid-js (and of react) resolves to one copy: the user's project's if their file's folder resolves one,
|
|
6
|
+
// else the checker's own.
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import crypto from "crypto";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import * as esbuild from "esbuild";
|
|
13
|
+
|
|
14
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const GUARD = path.join(here, "../guard");
|
|
16
|
+
const OWN = path.join(here, "../..");
|
|
17
|
+
|
|
18
|
+
const cacheDir = () => path.join(os.tmpdir(), "rhp-check-cache");
|
|
19
|
+
|
|
20
|
+
// A package as Node finds it from a folder (walking up through node_modules): its folder, and the folder whose
|
|
21
|
+
// node_modules holds it (resolving its name from there finds this copy). Null if there is none.
|
|
22
|
+
function findPackage(name, from) {
|
|
23
|
+
|
|
24
|
+
let dir = path.resolve(from);
|
|
25
|
+
|
|
26
|
+
for (;;) {
|
|
27
|
+
const candidate = path.join(dir, "node_modules", name, "package.json");
|
|
28
|
+
if (fs.existsSync(candidate)) return { root: fs.realpathSync(path.dirname(candidate)), base: dir };
|
|
29
|
+
const up = path.dirname(dir);
|
|
30
|
+
if (up === dir) return null;
|
|
31
|
+
dir = up;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const versionOf = (root) => JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version;
|
|
36
|
+
|
|
37
|
+
// The packages a check uses: the user's where their folder has them, else the checker's
|
|
38
|
+
export function packages(dir) {
|
|
39
|
+
|
|
40
|
+
const pick = (name) => {
|
|
41
|
+
const user = findPackage(name, dir);
|
|
42
|
+
const found = user ?? findPackage(name, OWN);
|
|
43
|
+
return found && { ...found, version: versionOf(found.root), user: !!user };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return { rhp: pick("@bezda/rhp"), solid: pick("solid-js"), react: pick("react"), reactDom: pick("react-dom") };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The JSX of a file's project, from the nearest package.json that names react, solid-js or vite-plugin-solid: "react"
|
|
50
|
+
// for react without solid-js, "solid" for solid-js (or its Vite plugin) without react, else null
|
|
51
|
+
function projectJsx(file) {
|
|
52
|
+
|
|
53
|
+
for (let dir = path.dirname(path.resolve(file)); ; dir = path.dirname(dir)) {
|
|
54
|
+
const json = path.join(dir, "package.json");
|
|
55
|
+
if (fs.existsSync(json)) {
|
|
56
|
+
let pkg = {};
|
|
57
|
+
try {
|
|
58
|
+
pkg = JSON.parse(fs.readFileSync(json, "utf8"));
|
|
59
|
+
} catch {
|
|
60
|
+
// a package.json that doesn't parse says nothing
|
|
61
|
+
}
|
|
62
|
+
const deps = { ...pkg.peerDependencies, ...pkg.devDependencies, ...pkg.dependencies };
|
|
63
|
+
const react = "react" in deps;
|
|
64
|
+
const solid = "solid-js" in deps || "vite-plugin-solid" in deps;
|
|
65
|
+
if (react || solid) return react === solid ? null : react ? "react" : "solid";
|
|
66
|
+
}
|
|
67
|
+
if (path.dirname(dir) === dir) return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The format of a file: html, solid, react or module. A .jsx or .tsx file is React or Solid as its project says, then
|
|
72
|
+
// as its imports say (a React file with the automatic JSX runtime imports nothing from react).
|
|
73
|
+
export function detectFormat(file, text) {
|
|
74
|
+
|
|
75
|
+
const ext = path.extname(file ?? "").toLowerCase();
|
|
76
|
+
const imports = [...text.matchAll(/(?:from|import)\s*\(?\s*["']([^"']+)["']/g)].map((m) => m[1]);
|
|
77
|
+
const uses = (re) => imports.some((x) => re.test(x));
|
|
78
|
+
const react = uses(/^(react|react-dom|preact)(\/|$)|^@bezda\/rhp-react$|^next(\/|$)/);
|
|
79
|
+
|
|
80
|
+
if (ext === ".html" || ext === ".htm") return "html";
|
|
81
|
+
if (!ext && /^\s*(<!doctype html|<html|<head|<body)/i.test(text)) return "html";
|
|
82
|
+
if (ext === ".jsx" || ext === ".tsx") return projectJsx(file) ?? (react ? "react" : "solid");
|
|
83
|
+
if (react) return "react";
|
|
84
|
+
if (!ext && /<[A-Z][\w.]*[\s/>]/.test(text) && !uses(/^@bezda\/rhp\/standalone$/)) return "solid";
|
|
85
|
+
|
|
86
|
+
return "module";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The export list of rhp's standalone module, for the guarded one: rhp's own, then Solid's, as standalone.d.ts says
|
|
90
|
+
function standaloneSource(rhpRoot, guard) {
|
|
91
|
+
|
|
92
|
+
const types = fs.readFileSync(path.join(rhpRoot, "dist/standalone.d.ts"), "utf8");
|
|
93
|
+
const lines = types.split("\n").filter((l) => /^export\b/.test(l));
|
|
94
|
+
const out = [];
|
|
95
|
+
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
if (/from "\.\/index\.js"/.test(line)) out.push(line.replace('"./index.js"', '"@bezda/rhp"'));
|
|
98
|
+
else if (guard && /export \{ render \} from "solid-js\/web"/.test(line)) out.push(`export { render } from ${JSON.stringify(path.join(GUARD, "render.js"))};`);
|
|
99
|
+
else out.push(line);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return out.join("\n") + "\n";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A file's text, hashed with whatever else decides what is built from it
|
|
106
|
+
const hash = (...parts) => crypto.createHash("sha1").update(parts.join("\0")).digest("hex").slice(0, 16);
|
|
107
|
+
|
|
108
|
+
function guardHash() {
|
|
109
|
+
|
|
110
|
+
const files = fs.readdirSync(GUARD).sort().map((f) => fs.readFileSync(path.join(GUARD, f), "utf8"));
|
|
111
|
+
|
|
112
|
+
return hash(...files, fs.readFileSync(fileURLToPath(import.meta.url), "utf8"));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let babel;
|
|
116
|
+
async function compileSolid(file, text, rhpSource) {
|
|
117
|
+
|
|
118
|
+
babel ??= { core: await import("@babel/core"), preset: (await import("babel-preset-solid")).default };
|
|
119
|
+
let src = text;
|
|
120
|
+
|
|
121
|
+
// As rhp's own build does: isServer becomes a constant in rhp's source
|
|
122
|
+
const env = /^import \{ isServer \} from "\.\/env\.js";\n/m;
|
|
123
|
+
if (rhpSource && env.test(src)) src = src.replace(env, "").replace(/\bisServer\b/g, "false");
|
|
124
|
+
|
|
125
|
+
if (/\.tsx$/.test(file)) src = (await esbuild.transform(src, { loader: "tsx", jsx: "preserve", sourcefile: file })).code;
|
|
126
|
+
|
|
127
|
+
const key = hash(file, src, babel.core.version, versionOf(findPackage("babel-preset-solid", OWN).root));
|
|
128
|
+
const cached = path.join(cacheDir(), "babel", key + ".js");
|
|
129
|
+
if (fs.existsSync(cached)) return fs.readFileSync(cached, "utf8");
|
|
130
|
+
|
|
131
|
+
const out = await babel.core.transformAsync(src, { presets: [[babel.preset, {}]], filename: file, babelrc: false, configFile: false, sourceMaps: "inline" });
|
|
132
|
+
fs.mkdirSync(path.dirname(cached), { recursive: true });
|
|
133
|
+
fs.writeFileSync(cached, out.code);
|
|
134
|
+
|
|
135
|
+
return out.code;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const ASSETS = [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".ico", ".bmp", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp4", ".webm", ".mp3", ".wav"];
|
|
139
|
+
const PINNED = /^(solid-js|react|react-dom|scheduler)(\/.*)?$/;
|
|
140
|
+
const SKIP = { rhpCheckSkip: true };
|
|
141
|
+
|
|
142
|
+
// esbuild plugins: rhp (guarded or not) and one copy of Solid and React, Solid's JSX, and Vite's ?url, ?raw, ?inline
|
|
143
|
+
function plugins({ format, guard, pkgs, rhpSource }) {
|
|
144
|
+
|
|
145
|
+
const roots = { "solid-js": pkgs.solid, react: pkgs.react, "react-dom": pkgs.reactDom };
|
|
146
|
+
const rhpRoot = pkgs.rhp.root;
|
|
147
|
+
const rhpExports = JSON.parse(fs.readFileSync(path.join(rhpRoot, "package.json"), "utf8")).exports ?? {};
|
|
148
|
+
const inRhp = (p) => p.startsWith(path.join(rhpRoot, "dist") + path.sep);
|
|
149
|
+
|
|
150
|
+
const resolveRhp = (sub) => {
|
|
151
|
+
const entry = rhpExports["./" + sub];
|
|
152
|
+
const target = typeof entry === "string" ? entry : entry?.default ?? entry?.browser ?? entry?.import;
|
|
153
|
+
return target ? path.join(rhpRoot, target) : path.join(rhpRoot, "dist", sub);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const pin = {
|
|
157
|
+
name: "rhp-check-pin",
|
|
158
|
+
setup(b) {
|
|
159
|
+
b.onResolve({ filter: PINNED }, async (a) => {
|
|
160
|
+
if (a.pluginData?.rhpCheckSkip) return undefined;
|
|
161
|
+
const name = a.path.match(PINNED)[1];
|
|
162
|
+
const pkg = roots[name] ?? (name === "scheduler" ? roots["react-dom"] : null);
|
|
163
|
+
if (!pkg) return undefined;
|
|
164
|
+
const r = await b.resolve(a.path, { kind: a.kind, resolveDir: pkg.base, pluginData: SKIP });
|
|
165
|
+
return r.errors.length ? undefined : { path: r.path, sideEffects: r.sideEffects };
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const rhp = {
|
|
171
|
+
name: "rhp-check-rhp",
|
|
172
|
+
setup(b) {
|
|
173
|
+
b.onResolve({ filter: /^@bezda\/rhp(\/.*)?$|^rhp-check:/ }, (a) => {
|
|
174
|
+
if (a.path === "rhp-check:rhp") return { path: path.join(rhpRoot, "dist/source/index.js") };
|
|
175
|
+
if (a.path === "@bezda/rhp") return { path: guard ? path.join(GUARD, "rhp.js") : path.join(rhpRoot, "dist/source/index.js") };
|
|
176
|
+
if (a.path === "@bezda/rhp/standalone") return { path: "standalone", namespace: "rhp-check" };
|
|
177
|
+
if (a.path.startsWith("@bezda/rhp/")) return { path: resolveRhp(a.path.slice("@bezda/rhp/".length)) };
|
|
178
|
+
return undefined;
|
|
179
|
+
});
|
|
180
|
+
b.onLoad({ filter: /^standalone$/, namespace: "rhp-check" }, () => ({ contents: standaloneSource(rhpRoot, guard), loader: "js", resolveDir: GUARD }));
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const solidJsx = {
|
|
185
|
+
name: "rhp-check-solid-jsx",
|
|
186
|
+
setup(b) {
|
|
187
|
+
b.onLoad({ filter: /\.(jsx|tsx)$/ }, async (a) => {
|
|
188
|
+
const ours = inRhp(a.path);
|
|
189
|
+
if (!ours && (format !== "solid" || a.path.includes(`${path.sep}node_modules${path.sep}`))) return undefined;
|
|
190
|
+
const text = await fs.promises.readFile(a.path, "utf8");
|
|
191
|
+
try {
|
|
192
|
+
return { contents: await compileSolid(a.path, text, ours && rhpSource), loader: "js", resolveDir: path.dirname(a.path) };
|
|
193
|
+
} catch (e) {
|
|
194
|
+
return { errors: [{ text: (e.message ?? String(e)).replace(/^[^:]*: /, "").split("\n")[0], location: e.loc ? { file: a.path, line: e.loc.line, column: e.loc.column } : null }] };
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const vite = {
|
|
201
|
+
name: "rhp-check-vite-queries",
|
|
202
|
+
setup(b) {
|
|
203
|
+
b.onResolve({ filter: /\?(url|raw|inline)$/ }, (a) => {
|
|
204
|
+
const [file, query] = a.path.split("?");
|
|
205
|
+
return { path: path.resolve(a.resolveDir, file), namespace: "rhp-check-asset", pluginData: { query } };
|
|
206
|
+
});
|
|
207
|
+
b.onLoad({ filter: /.*/, namespace: "rhp-check-asset" }, async (a) => ({
|
|
208
|
+
contents: await fs.promises.readFile(a.path),
|
|
209
|
+
loader: a.pluginData.query === "raw" ? "text" : a.pluginData.query === "inline" ? "dataurl" : "file",
|
|
210
|
+
resolveDir: path.dirname(a.path),
|
|
211
|
+
}));
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
return [pin, rhp, solidJsx, vite];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The code that mounts a file's component: the default export, or the only exported component, into #root (#chart for a
|
|
219
|
+
// module), unless importing the file already drew into it
|
|
220
|
+
function entrySource(file, format) {
|
|
221
|
+
|
|
222
|
+
const target = format === "module" ? "chart" : "root";
|
|
223
|
+
const pickCode = `
|
|
224
|
+
const target = document.getElementById(${JSON.stringify(target)});
|
|
225
|
+
const candidates = Object.entries(M).filter(([name, v]) => (name === "default" || /^[A-Z]/.test(name)) && (typeof v === "function" || (v && typeof v === "object" && v.$$typeof)) && !v.scope && !v.layout && v.css === undefined);
|
|
226
|
+
const component = typeof M.default === "function" || (M.default && M.default.$$typeof) ? M.default : candidates.length === 1 ? candidates[0][1] : null;
|
|
227
|
+
const store = (globalThis.__rhpCheck ??= { findings: [] });
|
|
228
|
+
if (component == null && candidates.length > 1) store.findings.push({ level: "error", code: "no-component", message: "The file exports several components (" + candidates.map((c) => c[0]).join(", ") + ") and no default one, so the checker can't tell which is the chart.", fix: "Export the chart component as the default export: export default function Chart() { ... }." });
|
|
229
|
+
const drawn = target.childNodes.length > 0;
|
|
230
|
+
`;
|
|
231
|
+
|
|
232
|
+
if (format === "solid") {
|
|
233
|
+
return `import * as M from ${JSON.stringify(file)};
|
|
234
|
+
import { render, createComponent } from "solid-js/web";
|
|
235
|
+
${pickCode}
|
|
236
|
+
if (component && !drawn) render(() => createComponent(component, {}), target);
|
|
237
|
+
`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (format === "react") {
|
|
241
|
+
return `import * as M from ${JSON.stringify(file)};
|
|
242
|
+
import { createElement } from "react";
|
|
243
|
+
import { createRoot } from "react-dom/client";
|
|
244
|
+
${pickCode}
|
|
245
|
+
if (component && !drawn) createRoot(target).render(createElement(component));
|
|
246
|
+
`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return `import * as M from ${JSON.stringify(file)};
|
|
250
|
+
import { render } from "@bezda/rhp/standalone";
|
|
251
|
+
${pickCode}
|
|
252
|
+
if (component && !drawn) render(() => component({}), target);
|
|
253
|
+
`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Bundles a solid, react or module file. Returns the files the page loads (path -> { body, type }), its HTML, and the
|
|
257
|
+
// problems found while building (syntax errors, imports that don't resolve).
|
|
258
|
+
export async function bundle({ file, format, guard = true }) {
|
|
259
|
+
|
|
260
|
+
const dir = path.dirname(file);
|
|
261
|
+
const pkgs = packages(dir);
|
|
262
|
+
|
|
263
|
+
const options = {
|
|
264
|
+
stdin: { contents: entrySource(file, format), resolveDir: dir, sourcefile: "rhp-check-entry.js", loader: "js" },
|
|
265
|
+
bundle: true,
|
|
266
|
+
write: false,
|
|
267
|
+
format: "esm",
|
|
268
|
+
platform: "browser",
|
|
269
|
+
target: "es2022",
|
|
270
|
+
outdir: "/rhp-check-out",
|
|
271
|
+
entryNames: "bundle",
|
|
272
|
+
assetNames: "[name]-[hash]",
|
|
273
|
+
publicPath: "/__rhp/",
|
|
274
|
+
sourcemap: "inline",
|
|
275
|
+
plugins: plugins({ format, guard, pkgs, rhpSource: true }),
|
|
276
|
+
loader: Object.fromEntries([...ASSETS.map((e) => [e, "file"]), [".module.css", "local-css"]]),
|
|
277
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
278
|
+
nodePaths: [path.join(OWN, "node_modules")],
|
|
279
|
+
jsx: "automatic",
|
|
280
|
+
jsxImportSource: "react",
|
|
281
|
+
logLevel: "silent",
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const result = await esbuild.build(options);
|
|
286
|
+
const files = new Map();
|
|
287
|
+
|
|
288
|
+
for (const out of result.outputFiles) {
|
|
289
|
+
const name = path.basename(out.path);
|
|
290
|
+
files.set("/__rhp/" + name, { body: Buffer.from(out.contents), type: contentType(name) });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const css = files.has("/__rhp/bundle.css") ? '<link rel="stylesheet" href="/__rhp/bundle.css">\n' : "";
|
|
294
|
+
const html = `<!doctype html>
|
|
295
|
+
<html lang="en">
|
|
296
|
+
<head>
|
|
297
|
+
<meta charset="utf-8">
|
|
298
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
299
|
+
<title>rhp check</title>
|
|
300
|
+
${css}</head>
|
|
301
|
+
<body>
|
|
302
|
+
<div id="${format === "module" ? "chart" : "root"}"></div>
|
|
303
|
+
<script type="module" src="/__rhp/bundle.js"></script>
|
|
304
|
+
</body>
|
|
305
|
+
</html>
|
|
306
|
+
`;
|
|
307
|
+
|
|
308
|
+
return { ok: true, files, html, problems: [] };
|
|
309
|
+
} catch (e) {
|
|
310
|
+
return { ok: false, files: new Map(), html: "", problems: buildProblems(e, dir) };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// esbuild's errors as findings
|
|
315
|
+
function buildProblems(e, dir) {
|
|
316
|
+
|
|
317
|
+
const errors = e.errors ?? [{ text: e.message ?? String(e) }];
|
|
318
|
+
|
|
319
|
+
return errors.map((err) => {
|
|
320
|
+
const loc = err.location;
|
|
321
|
+
const where = loc?.file ? `${path.relative(dir, path.resolve(dir, loc.file)) || loc.file}:${loc.line}:${loc.column + 1}` : "";
|
|
322
|
+
const missing = err.text.match(/^Could not resolve "([^"]+)"/);
|
|
323
|
+
if (missing) {
|
|
324
|
+
return {
|
|
325
|
+
level: "error",
|
|
326
|
+
code: "missing-import",
|
|
327
|
+
message: `${where ? where + ": " : ""}${err.text}.`,
|
|
328
|
+
fix: missing[1].startsWith(".") ? `Fix the path, or add the file ${missing[1]}.` : `Install ${missing[1]} in the project (npm install ${missing[1].split("/").slice(0, missing[1].startsWith("@") ? 2 : 1).join("/")}), or import it from a package the project has.`,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
level: "error",
|
|
333
|
+
code: "syntax-error",
|
|
334
|
+
message: `${where ? where + ": " : ""}${err.text}${loc?.lineText ? ` (${loc.lineText.trim().slice(0, 120)})` : ""}.`,
|
|
335
|
+
fix: "Fix the code at that line: the file must parse before anything can be drawn.",
|
|
336
|
+
};
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export const contentType = (name) => ({
|
|
341
|
+
".js": "text/javascript", ".mjs": "text/javascript", ".css": "text/css", ".html": "text/html", ".json": "application/json",
|
|
342
|
+
".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
|
|
343
|
+
".webp": "image/webp", ".avif": "image/avif", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2",
|
|
344
|
+
".ttf": "font/ttf", ".otf": "font/otf", ".map": "application/json", ".txt": "text/plain", ".csv": "text/csv",
|
|
345
|
+
}[path.extname(name).toLowerCase()] ?? "application/octet-stream");
|
|
346
|
+
|
|
347
|
+
// rhp's standalone module with the guard in it, for html pages: built once per rhp, Solid and guard, and kept on disk
|
|
348
|
+
export async function guardedStandalone(pkgs) {
|
|
349
|
+
|
|
350
|
+
// rhp's own build is in the key too, so a rebuilt rhp of the same version (a working copy) is never served stale
|
|
351
|
+
const built = ["dist/index.js", "dist/standalone.d.ts"].map((f) => fs.readFileSync(path.join(pkgs.rhp.root, f), "utf8"));
|
|
352
|
+
const key = hash(pkgs.rhp.root, pkgs.rhp.version, ...built, pkgs.solid.root, pkgs.solid.version, esbuild.version, guardHash());
|
|
353
|
+
const file = path.join(cacheDir(), `standalone-${pkgs.rhp.version}-${key}.js`);
|
|
354
|
+
if (fs.existsSync(file)) return file;
|
|
355
|
+
|
|
356
|
+
const result = await esbuild.build({
|
|
357
|
+
stdin: { contents: 'export * from "@bezda/rhp/standalone";', resolveDir: GUARD, sourcefile: "rhp-check-standalone.js", loader: "js" },
|
|
358
|
+
bundle: true,
|
|
359
|
+
write: false,
|
|
360
|
+
format: "esm",
|
|
361
|
+
platform: "browser",
|
|
362
|
+
target: "es2020",
|
|
363
|
+
minify: true,
|
|
364
|
+
plugins: plugins({ format: "module", guard: true, pkgs, rhpSource: true }),
|
|
365
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
366
|
+
logLevel: "silent",
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
fs.mkdirSync(cacheDir(), { recursive: true });
|
|
370
|
+
const tmp = file + "." + process.pid + ".tmp";
|
|
371
|
+
fs.writeFileSync(tmp, result.outputFiles[0].contents);
|
|
372
|
+
fs.renameSync(tmp, file);
|
|
373
|
+
|
|
374
|
+
return file;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Problems an html page has before it runs: a script that doesn't parse, rhp loaded by a plain script tag, a bare
|
|
378
|
+
// import with no import map
|
|
379
|
+
export async function htmlProblems(file, text) {
|
|
380
|
+
|
|
381
|
+
const problems = [];
|
|
382
|
+
const lineAt = (index) => text.slice(0, index).split("\n").length;
|
|
383
|
+
const scripts = [...text.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)];
|
|
384
|
+
const importMap = scripts.find((m) => /type\s*=\s*["']?importmap/i.test(m[1]));
|
|
385
|
+
let mapped = {};
|
|
386
|
+
|
|
387
|
+
if (importMap) {
|
|
388
|
+
try {
|
|
389
|
+
mapped = JSON.parse(importMap[2]).imports ?? {};
|
|
390
|
+
} catch (e) {
|
|
391
|
+
problems.push({ level: "error", code: "syntax-error", message: `${path.basename(file)}:${lineAt(importMap.index)}: the import map is not valid JSON (${e.message}).`, fix: "Write the import map as JSON: { \"imports\": { \"@bezda/rhp/standalone\": \"https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js\" } }." });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
for (const m of scripts) {
|
|
396
|
+
const attrs = m[1];
|
|
397
|
+
const src = attrs.match(/\bsrc\s*=\s*["']?([^"'\s>]+)/i)?.[1];
|
|
398
|
+
const module = /type\s*=\s*["']?module/i.test(attrs);
|
|
399
|
+
if (/importmap|json|text\/(?!javascript)/i.test(attrs.match(/type\s*=\s*["']?([^"'\s>]+)/i)?.[1] ?? "")) continue;
|
|
400
|
+
|
|
401
|
+
if (src && /@bezda\/rhp/.test(src) && !module) {
|
|
402
|
+
problems.push({
|
|
403
|
+
level: "error",
|
|
404
|
+
code: "plain-script-tag",
|
|
405
|
+
message: `${path.basename(file)}:${lineAt(m.index)}: rhp is loaded with a plain <script src="${src}">, but it is an ES module, so the browser can't run it.`,
|
|
406
|
+
fix: 'Load it with an import map and a module script: <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>, then <script type="module">import { Chart, html, render } from "@bezda/rhp/standalone"; ...</script>.',
|
|
407
|
+
});
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (src || !m[2].trim()) continue;
|
|
412
|
+
|
|
413
|
+
const body = m[2];
|
|
414
|
+
const start = lineAt(m.index + m[0].indexOf(">") + 1);
|
|
415
|
+
try {
|
|
416
|
+
await esbuild.transform(body, { loader: "js", format: module ? "esm" : undefined, logLevel: "silent" });
|
|
417
|
+
} catch (e) {
|
|
418
|
+
const err = e.errors?.[0];
|
|
419
|
+
if (err) {
|
|
420
|
+
const line = start + (err.location?.line ?? 1) - 1;
|
|
421
|
+
problems.push({ level: "error", code: "syntax-error", message: `${path.basename(file)}:${line}:${(err.location?.column ?? 0) + 1}: ${err.text}${err.location?.lineText ? ` (${err.location.lineText.trim().slice(0, 120)})` : ""}.`, fix: "Fix the script at that line: it must parse before anything can be drawn." });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (module) {
|
|
426
|
+
for (const [, spec] of body.matchAll(/(?:from|import)\s*\(?\s*["']([^"'./][^"']*)["']/g)) {
|
|
427
|
+
if (/^(https?:|data:|blob:)/.test(spec) || spec in mapped || Object.keys(mapped).some((k) => k.endsWith("/") && spec.startsWith(k))) continue;
|
|
428
|
+
problems.push({
|
|
429
|
+
level: "error",
|
|
430
|
+
code: "missing-import-map",
|
|
431
|
+
message: `${path.basename(file)}:${start}: the module script imports "${spec}", but no import map says where it comes from.`,
|
|
432
|
+
fix: spec.startsWith("@bezda/rhp") ? 'Add <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script> before the module script.' : `Add "${spec}" to the page's import map, or import it by URL.`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return problems;
|
|
439
|
+
}
|