plexus-peers 0.2.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/README.md +85 -0
- package/bin/plexus.js +134 -0
- package/lib/ansi.js +19 -0
- package/lib/engine.js +31 -0
- package/lib/fix.js +109 -0
- package/lib/format.js +17 -0
- package/lib/graph.js +100 -0
- package/lib/html-escape.js +22 -0
- package/lib/npm.js +85 -0
- package/lib/render-html.js +541 -0
- package/lib/run-analysis.js +181 -0
- package/lib/server.js +77 -0
- package/lib/terminal.js +91 -0
- package/package.json +31 -0
- package/public/index.html +217 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const { color } = require('./ansi');
|
|
8
|
+
const { formatBytes } = require('./format');
|
|
9
|
+
const { createFsContext, buildGraph } = require('./graph');
|
|
10
|
+
const { resolveConflicts } = require('./fix');
|
|
11
|
+
const { getBundleSize, createRegistryContext, registryFetchConcurrency } = require('./npm');
|
|
12
|
+
const { renderHtml } = require('./render-html');
|
|
13
|
+
const { printGraph, printSummary } = require('./terminal');
|
|
14
|
+
|
|
15
|
+
function openHtmlReport(outPath) {
|
|
16
|
+
const opts = { stdio: 'ignore' };
|
|
17
|
+
try {
|
|
18
|
+
if (process.platform === 'darwin') execSync(`open "${outPath}"`, opts);
|
|
19
|
+
else if (process.platform === 'win32') execSync(`cmd /c start "" "${outPath}"`, opts);
|
|
20
|
+
else execSync(`xdg-open "${outPath}"`, opts);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} opts
|
|
29
|
+
* @param {string} opts.rootDir - directory containing package.json + node_modules
|
|
30
|
+
* @param {object} opts.flags
|
|
31
|
+
*/
|
|
32
|
+
async function runFilesystemAnalysis(opts) {
|
|
33
|
+
const rootDir = path.resolve(opts.rootDir);
|
|
34
|
+
const {
|
|
35
|
+
conflictsOnly = false,
|
|
36
|
+
html: htmlMode = false,
|
|
37
|
+
fix: fixMode = false,
|
|
38
|
+
bundleSize: bundleSizeMode = false,
|
|
39
|
+
focusPkg = null,
|
|
40
|
+
outFile,
|
|
41
|
+
} = opts.flags ?? {};
|
|
42
|
+
|
|
43
|
+
const rootPkg = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8'));
|
|
44
|
+
const directDeps = {
|
|
45
|
+
...(rootPkg.dependencies ?? {}),
|
|
46
|
+
...(rootPkg.devDependencies ?? {}),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (focusPkg && !directDeps[focusPkg]) {
|
|
50
|
+
console.log(color(`Package "${focusPkg}" not found in dependencies.`, 'red'));
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ctx = createFsContext(rootDir);
|
|
56
|
+
const graph = buildGraph(directDeps, ctx);
|
|
57
|
+
|
|
58
|
+
const resolutions = fixMode
|
|
59
|
+
? resolveConflicts(graph, directDeps, ctx.getInstalledVersion).resolutions
|
|
60
|
+
: {};
|
|
61
|
+
|
|
62
|
+
if (fixMode && !htmlMode) {
|
|
63
|
+
if (Object.keys(resolutions).length === 0) {
|
|
64
|
+
console.log(color('\n✓ No conflicts to resolve.', 'green'));
|
|
65
|
+
} else {
|
|
66
|
+
console.log(color('\n── Resolution Plan ──────────────────────────────────', 'bold'));
|
|
67
|
+
for (const [pkg, r] of Object.entries(resolutions)) {
|
|
68
|
+
console.log(
|
|
69
|
+
`\n ${color(pkg, 'cyan')} ${color(r.current, 'gray')} → ${color(`^${r.latest}`, 'green')}`,
|
|
70
|
+
);
|
|
71
|
+
if (r.fixes.length)
|
|
72
|
+
console.log(` fixes: ${r.fixes.map(f => color(f, 'green')).join(', ')}`);
|
|
73
|
+
if (r.stillConflicts.length)
|
|
74
|
+
console.log(` still ✗: ${r.stillConflicts.map(f => color(f, 'red')).join(', ')}`);
|
|
75
|
+
if (r.cascades.length) {
|
|
76
|
+
console.log(color(` cascades (${r.cascades.length}):`, 'yellow'));
|
|
77
|
+
for (const c of r.cascades)
|
|
78
|
+
console.log(
|
|
79
|
+
` ⚠ ${color(c.peerName, 'cyan')} needs ${color(c.peerRange, 'gray')}, have v${c.installedVer}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
console.log(color('\n── Suggested package.json changes ───────────────────', 'bold'));
|
|
84
|
+
for (const [pkg, r] of Object.entries(resolutions)) {
|
|
85
|
+
console.log(
|
|
86
|
+
` "${color(pkg, 'cyan')}": "${color(r.current, 'gray')}" → "${color(`^${r.latest}`, 'green')}"`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (htmlMode) {
|
|
94
|
+
let bundleSizes = {};
|
|
95
|
+
if (bundleSizeMode) {
|
|
96
|
+
const directKeys = Object.keys(directDeps);
|
|
97
|
+
console.log(color(`\nQuerying bundlephobia for ${directKeys.length} packages…`, 'gray'));
|
|
98
|
+
for (const pkg of directKeys) {
|
|
99
|
+
const ver = ctx.getInstalledVersion(pkg);
|
|
100
|
+
if (!ver) continue;
|
|
101
|
+
process.stdout.write(` ${color(pkg, 'cyan')} … `);
|
|
102
|
+
const result = await getBundleSize(pkg, ver);
|
|
103
|
+
if (result) {
|
|
104
|
+
bundleSizes[pkg] = result;
|
|
105
|
+
console.log(`${formatBytes(result.size)} (gzip: ${formatBytes(result.gzip)})`);
|
|
106
|
+
} else {
|
|
107
|
+
console.log(color('n/a', 'gray'));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const html = renderHtml(graph, directDeps, rootPkg, resolutions, bundleSizes, {
|
|
113
|
+
source: 'filesystem',
|
|
114
|
+
});
|
|
115
|
+
const outPath = outFile ? path.resolve(outFile) : path.join(rootDir, 'dep-graph.html');
|
|
116
|
+
fs.writeFileSync(outPath, html, 'utf8');
|
|
117
|
+
console.log(color(`✓ Report written to ${outPath}`, 'green'));
|
|
118
|
+
if (!openHtmlReport(outPath)) {
|
|
119
|
+
console.log(color(' Open the HTML file in your browser to view it.', 'gray'));
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log(
|
|
125
|
+
color('★ Plexus', 'bold', 'cyan') + color(' — peer dependency analysis', 'gray'),
|
|
126
|
+
);
|
|
127
|
+
console.log(
|
|
128
|
+
color(` Project: ${rootPkg.name ?? '(unnamed)'} v${rootPkg.version ?? '?'}`, 'gray'),
|
|
129
|
+
);
|
|
130
|
+
if (focusPkg) console.log(color(` Focused on: ${focusPkg}`, 'yellow'));
|
|
131
|
+
if (conflictsOnly) console.log(color(' Showing conflicts only', 'yellow'));
|
|
132
|
+
|
|
133
|
+
const conflicts = printGraph(graph, directDeps, { conflictsOnly, focusPkg });
|
|
134
|
+
printSummary(conflicts, directDeps, graph);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function runRegistryAnalysis(rootPkg, flags = {}) {
|
|
138
|
+
const directDeps = {
|
|
139
|
+
...(rootPkg.dependencies ?? {}),
|
|
140
|
+
...(rootPkg.devDependencies ?? {}),
|
|
141
|
+
};
|
|
142
|
+
const maxDirect = Math.max(1, Number(process.env.PLEXUS_MAX_DIRECT_DEPS || 250) || 250);
|
|
143
|
+
const directCount = Object.keys(directDeps).length;
|
|
144
|
+
if (directCount > maxDirect) {
|
|
145
|
+
const err = new Error(`Too many direct dependencies (${directCount}; max ${maxDirect}).`);
|
|
146
|
+
err.statusCode = 400;
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
const ctx = await createRegistryContext(directDeps);
|
|
150
|
+
const graph = buildGraph(directDeps, ctx);
|
|
151
|
+
const fixMode = flags.fix === true;
|
|
152
|
+
const resolutions = fixMode
|
|
153
|
+
? resolveConflicts(graph, directDeps, ctx.getInstalledVersion, { silent: true }).resolutions
|
|
154
|
+
: {};
|
|
155
|
+
let bundleSizes = {};
|
|
156
|
+
if (flags.bundleSize) {
|
|
157
|
+
const names = Object.keys(directDeps);
|
|
158
|
+
const limit = registryFetchConcurrency();
|
|
159
|
+
for (let i = 0; i < names.length; i += limit) {
|
|
160
|
+
const batch = names.slice(i, i + limit);
|
|
161
|
+
await Promise.all(
|
|
162
|
+
batch.map(async pkg => {
|
|
163
|
+
const ver = ctx.getInstalledVersion(pkg);
|
|
164
|
+
if (!ver) return;
|
|
165
|
+
const result = await getBundleSize(pkg, ver);
|
|
166
|
+
if (result) bundleSizes[pkg] = result;
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const html = renderHtml(graph, directDeps, rootPkg, resolutions, bundleSizes, {
|
|
172
|
+
source: 'registry',
|
|
173
|
+
});
|
|
174
|
+
return { graph, directDeps, resolutions, bundleSizes, html };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
openHtmlReport,
|
|
179
|
+
runFilesystemAnalysis,
|
|
180
|
+
runRegistryAnalysis,
|
|
181
|
+
};
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const express = require('express');
|
|
5
|
+
const { runRegistryAnalysis } = require('./engine');
|
|
6
|
+
|
|
7
|
+
/** Comma-separated origins, e.g. https://you.github.io — required for GH Pages → Render API. */
|
|
8
|
+
function allowedOrigins() {
|
|
9
|
+
const raw = process.env.PLEXUS_ALLOWED_ORIGINS;
|
|
10
|
+
if (!raw) return [];
|
|
11
|
+
return raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function corsForPages(req, res, next) {
|
|
15
|
+
const list = allowedOrigins();
|
|
16
|
+
if (list.length === 0) return next();
|
|
17
|
+
const origin = req.headers.origin;
|
|
18
|
+
if (origin && list.includes(origin)) {
|
|
19
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
20
|
+
res.setHeader('Vary', 'Origin');
|
|
21
|
+
}
|
|
22
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
23
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
24
|
+
if (req.method === 'OPTIONS') {
|
|
25
|
+
return res.sendStatus(204);
|
|
26
|
+
}
|
|
27
|
+
next();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createApp() {
|
|
31
|
+
const app = express();
|
|
32
|
+
app.use(corsForPages);
|
|
33
|
+
app.use(express.json({ limit: '3mb' }));
|
|
34
|
+
|
|
35
|
+
const publicDir = path.join(__dirname, '..', 'public');
|
|
36
|
+
app.use(express.static(publicDir));
|
|
37
|
+
|
|
38
|
+
app.post('/api/analyze', async (req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const body = req.body;
|
|
41
|
+
const raw = body.packageJson ?? body;
|
|
42
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
43
|
+
return res.status(400).type('json').send({ error: 'Send JSON: { "packageJson": { ... } } or raw package.json fields' });
|
|
44
|
+
}
|
|
45
|
+
const pkg = { ...raw };
|
|
46
|
+
delete pkg.private;
|
|
47
|
+
delete pkg.main;
|
|
48
|
+
const flags = {
|
|
49
|
+
fix: body.fix === true,
|
|
50
|
+
bundleSize: body.bundleSize === true,
|
|
51
|
+
};
|
|
52
|
+
const result = await runRegistryAnalysis(pkg, flags);
|
|
53
|
+
res.type('html').send(result.html);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.error(e);
|
|
56
|
+
const status =
|
|
57
|
+
typeof e.statusCode === 'number' && e.statusCode >= 400 && e.statusCode < 600
|
|
58
|
+
? e.statusCode
|
|
59
|
+
: 500;
|
|
60
|
+
res.status(status).type('json').send({ error: String(e.message || e) });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return app;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function startServer(opts = {}) {
|
|
68
|
+
const port = Number(process.env.PORT || opts.port || 3847) || 3847;
|
|
69
|
+
const host = process.env.HOST || '0.0.0.0';
|
|
70
|
+
const app = createApp();
|
|
71
|
+
app.listen(port, host, () => {
|
|
72
|
+
const openHost = host === '0.0.0.0' ? '127.0.0.1' : host;
|
|
73
|
+
console.log(`Plexus web: http://${openHost}:${port}`);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { createApp, startServer };
|
package/lib/terminal.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { color } = require('./ansi');
|
|
4
|
+
|
|
5
|
+
function printGraph(graph, directDeps, options = {}) {
|
|
6
|
+
const { conflictsOnly = false, focusPkg = null } = options;
|
|
7
|
+
const allConflicts = [];
|
|
8
|
+
|
|
9
|
+
const entries = focusPkg
|
|
10
|
+
? Object.entries(graph).filter(
|
|
11
|
+
([name]) => name === focusPkg || graph[focusPkg]?.peerDeps?.some(p => p.name === name),
|
|
12
|
+
)
|
|
13
|
+
: Object.entries(graph);
|
|
14
|
+
|
|
15
|
+
const directSet = new Set(Object.keys(directDeps));
|
|
16
|
+
|
|
17
|
+
for (const [pkgName, info] of entries.sort(([a], [b]) => a.localeCompare(b))) {
|
|
18
|
+
if (conflictsOnly && info.peerDeps.every(p => p.ok)) continue;
|
|
19
|
+
|
|
20
|
+
const isDirect = directSet.has(pkgName);
|
|
21
|
+
const versionStr = info.missing
|
|
22
|
+
? color('NOT INSTALLED', 'red', 'bold')
|
|
23
|
+
: color(`v${info.version}`, 'gray');
|
|
24
|
+
|
|
25
|
+
const tag = isDirect ? color(' [direct]', 'blue') : '';
|
|
26
|
+
console.log(`\n${color(pkgName, 'bold', 'cyan')}${tag} ${versionStr}`);
|
|
27
|
+
|
|
28
|
+
if (info.requiredBy.length > 0) {
|
|
29
|
+
console.log(
|
|
30
|
+
` ${color('←', 'gray')} required as peer by: ${info.requiredBy.map(r => color(r, 'magenta')).join(', ')}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (info.peerDeps.length === 0) {
|
|
35
|
+
if (!conflictsOnly) console.log(` ${color('no peer dependencies', 'gray')}`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log(` ${color('peer dependencies:', 'bold')}`);
|
|
40
|
+
for (const { name, range, installed, ok } of info.peerDeps) {
|
|
41
|
+
const status = ok
|
|
42
|
+
? color('✓', 'green')
|
|
43
|
+
: installed
|
|
44
|
+
? color('✗', 'red', 'bold')
|
|
45
|
+
: color('?', 'yellow');
|
|
46
|
+
|
|
47
|
+
const installedStr = installed
|
|
48
|
+
? ok
|
|
49
|
+
? color(`(installed: v${installed})`, 'gray')
|
|
50
|
+
: color(`(installed: v${installed} — MISMATCH)`, 'red')
|
|
51
|
+
: color('(not installed)', 'yellow');
|
|
52
|
+
|
|
53
|
+
console.log(` ${status} ${color(name, 'cyan')} ${color(range, 'gray')} ${installedStr}`);
|
|
54
|
+
|
|
55
|
+
if (!ok) {
|
|
56
|
+
allConflicts.push({ pkg: pkgName, peer: name, required: range, installed });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return allConflicts;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function printSummary(conflicts, directDeps, graph) {
|
|
65
|
+
console.log(`\n${'─'.repeat(60)}`);
|
|
66
|
+
console.log(color('SUMMARY', 'bold'));
|
|
67
|
+
console.log('─'.repeat(60));
|
|
68
|
+
|
|
69
|
+
const total = Object.keys(graph).length;
|
|
70
|
+
const directCount = Object.keys(directDeps).length;
|
|
71
|
+
console.log(
|
|
72
|
+
` Direct deps: ${color(directCount, 'cyan')} | Total in graph: ${color(total, 'cyan')}`,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (conflicts.length === 0) {
|
|
76
|
+
console.log(color('\n ✓ No peer dependency conflicts detected!', 'green', 'bold'));
|
|
77
|
+
} else {
|
|
78
|
+
console.log(
|
|
79
|
+
color(`\n ✗ ${conflicts.length} peer dependency conflict(s) found:`, 'red', 'bold'),
|
|
80
|
+
);
|
|
81
|
+
for (const { pkg, peer, required, installed } of conflicts) {
|
|
82
|
+
const installedStr = installed ? `v${installed}` : 'not installed';
|
|
83
|
+
console.log(
|
|
84
|
+
` • ${color(pkg, 'cyan')} requires ${color(peer, 'cyan')} ${color(required, 'gray')} — got ${color(installedStr, 'red')}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
console.log(color('\n Run `npm outdated` or `yarn upgrade-interactive` to update.', 'yellow'));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { printGraph, printSummary };
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "plexus-peers",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Plexus — CLI and mini web UI for peer dependency analysis and BundlePhobia-style size hints",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/arnaudmanaranche/plexus.git"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"plexus-peers": "bin/plexus.js"
|
|
12
|
+
},
|
|
13
|
+
"main": "lib/engine.js",
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"lib",
|
|
17
|
+
"public"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"start": "node bin/plexus.js serve",
|
|
24
|
+
"plexus": "node bin/plexus.js",
|
|
25
|
+
"plexus-peers": "node bin/plexus.js"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"express": "^4.21.2",
|
|
29
|
+
"semver": "^7.7.1"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<!-- Optional: override API host (e.g. custom GitHub Pages domain). Empty = rules in script below. -->
|
|
7
|
+
<meta name="plexus-api-base" content="">
|
|
8
|
+
<title>Plexus — package.json analysis</title>
|
|
9
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
10
|
+
<script>
|
|
11
|
+
tailwind.config = {
|
|
12
|
+
theme: {
|
|
13
|
+
extend: {
|
|
14
|
+
colors: {
|
|
15
|
+
plexus: {
|
|
16
|
+
void: '#050505',
|
|
17
|
+
base: '#0a0a0a',
|
|
18
|
+
raised: '#111111',
|
|
19
|
+
subtle: '#171717',
|
|
20
|
+
code: '#1c1c1c',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
</script>
|
|
27
|
+
</head>
|
|
28
|
+
<body class="relative box-border min-h-screen overflow-x-hidden bg-plexus-base text-zinc-300 font-sans antialiased">
|
|
29
|
+
<!-- Ambient background (low contrast, non-interactive) -->
|
|
30
|
+
<div class="pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden="true">
|
|
31
|
+
<div class="absolute left-1/2 top-0 h-[min(78vh,600px)] w-[min(92vw,820px)] -translate-x-1/2 -translate-y-1/3 rounded-full bg-gradient-to-b from-white/[0.055] via-zinc-500/[0.04] to-transparent blur-3xl"></div>
|
|
32
|
+
<div class="absolute -left-36 top-[28%] h-[min(28rem,50vw)] w-[min(28rem,50vw)] rounded-full bg-white/[0.028] blur-3xl sm:-left-24"></div>
|
|
33
|
+
<div class="absolute -right-28 bottom-[18%] h-[min(22rem,45vw)] w-[min(22rem,45vw)] rounded-full bg-zinc-400/[0.04] blur-3xl sm:-right-20"></div>
|
|
34
|
+
<div class="absolute inset-0 opacity-[0.4] [background-image:radial-gradient(rgba(255,255,255,0.042)_1px,transparent_1px)] [background-size:40px_40px]"></div>
|
|
35
|
+
<div class="absolute inset-0 bg-gradient-to-b from-plexus-void/25 via-transparent to-plexus-void/75"></div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<main class="relative z-10 min-h-screen flex flex-col items-center justify-center px-5 sm:px-8 py-16 sm:py-20 md:py-28">
|
|
39
|
+
<div class="w-full max-w-4xl mx-auto text-center">
|
|
40
|
+
<!-- Version: keep in sync with package.json -->
|
|
41
|
+
<div class="relative z-[1] flex flex-wrap items-center justify-center gap-3 sm:gap-4 mb-3 md:mb-4">
|
|
42
|
+
<h1 class="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-semibold tracking-tight text-white [text-shadow:0_1px_2px_rgba(0,0,0,0.85),0_0_40px_rgba(10,10,10,0.9)]">
|
|
43
|
+
Plexus
|
|
44
|
+
</h1>
|
|
45
|
+
<span class="inline-flex items-center rounded-lg border border-zinc-700/80 bg-plexus-code px-2.5 py-1 text-xs sm:text-sm font-mono font-medium text-zinc-400 tabular-nums shrink-0" translate="no">v0.2.0</span>
|
|
46
|
+
</div>
|
|
47
|
+
<p class="text-sm text-zinc-500 mb-5 md:mb-6">
|
|
48
|
+
<a href="https://github.com/arnaudmanaranche/plexus" target="_blank" rel="noopener noreferrer" class="text-zinc-400 hover:text-white underline-offset-4 hover:underline transition-colors">GitHub</a>
|
|
49
|
+
</p>
|
|
50
|
+
<p class="text-lg sm:text-xl md:text-2xl text-zinc-500 font-light leading-relaxed max-w-3xl mx-auto mb-14 md:mb-16">
|
|
51
|
+
Upload a
|
|
52
|
+
<code class="text-base sm:text-lg md:text-xl font-mono text-zinc-400 bg-plexus-code px-2 py-0.5 rounded-md">package.json</code>
|
|
53
|
+
— peer dependency report from the npm registry, no local
|
|
54
|
+
<code class="text-base sm:text-lg md:text-xl font-mono text-zinc-400 bg-plexus-code px-2 py-0.5 rounded-md">node_modules</code>.
|
|
55
|
+
</p>
|
|
56
|
+
|
|
57
|
+
<div class="relative z-[1] w-full max-w-2xl mx-auto rounded-2xl border border-zinc-800/90 bg-plexus-raised/92 backdrop-blur-md p-8 sm:p-10 md:p-12 text-left shadow-[0_24px_80px_rgba(0,0,0,0.45),0_0_0_1px_rgba(255,255,255,0.04)]">
|
|
58
|
+
<label class="block border-2 border-dashed border-zinc-800 rounded-xl py-14 sm:py-16 px-6 text-center cursor-pointer text-zinc-500 text-lg sm:text-xl transition-colors duration-200 hover:border-zinc-600 hover:text-zinc-300 hover:bg-plexus-subtle/50">
|
|
59
|
+
<input type="file" id="file" class="hidden" accept=".json,application/json">
|
|
60
|
+
<span id="fileLabel">Choose a package.json file</span>
|
|
61
|
+
</label>
|
|
62
|
+
|
|
63
|
+
<div class="mt-8 flex flex-col gap-4">
|
|
64
|
+
<label class="flex items-start gap-3 cursor-pointer text-zinc-500 text-base sm:text-lg select-none leading-snug">
|
|
65
|
+
<input type="checkbox" id="optFix" class="mt-1 size-4 shrink-0 rounded border-zinc-700 bg-plexus-subtle text-zinc-200 focus:ring-2 focus:ring-zinc-600 focus:ring-offset-0 focus:ring-offset-plexus-base accent-zinc-300">
|
|
66
|
+
<span>Include <code class="text-sm font-mono text-zinc-400 bg-plexus-code px-1.5 py-0.5 rounded">--fix</code> resolution plan (extra npm requests)</span>
|
|
67
|
+
</label>
|
|
68
|
+
<label class="flex items-start gap-3 cursor-pointer text-zinc-500 text-base sm:text-lg select-none leading-snug">
|
|
69
|
+
<input type="checkbox" id="optBundle" class="mt-1 size-4 shrink-0 rounded border-zinc-700 bg-plexus-subtle text-zinc-200 focus:ring-2 focus:ring-zinc-600 focus:ring-offset-0 focus:ring-offset-plexus-base accent-zinc-300">
|
|
70
|
+
<span>BundlePhobia sizes (slower)</span>
|
|
71
|
+
</label>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<button type="button" id="run" disabled
|
|
75
|
+
class="mt-10 w-full py-4 sm:py-5 rounded-xl bg-zinc-200 text-zinc-950 text-lg sm:text-xl font-semibold tracking-tight cursor-pointer transition-colors hover:bg-white disabled:opacity-40 disabled:hover:bg-zinc-200 disabled:cursor-not-allowed">
|
|
76
|
+
Generate report
|
|
77
|
+
</button>
|
|
78
|
+
|
|
79
|
+
<p class="mt-8 text-center text-sm sm:text-base text-zinc-600 leading-relaxed">
|
|
80
|
+
CLI:
|
|
81
|
+
<code class="font-mono text-zinc-500 bg-plexus-code px-2 py-0.5 rounded-md">npx plexus-peers --html</code>
|
|
82
|
+
from your project root for disk sizes and transitive peers via
|
|
83
|
+
<code class="font-mono text-zinc-500 bg-plexus-code px-2 py-0.5 rounded-md">node_modules</code>.
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</main>
|
|
88
|
+
|
|
89
|
+
<div id="plexus-toast" class="fixed top-6 right-4 z-50 max-w-md w-[calc(100vw_-_2rem)] opacity-0 -translate-y-2 pointer-events-none transition-all duration-300 ease-out" role="alert" aria-live="assertive" aria-atomic="true">
|
|
90
|
+
<div class="pointer-events-auto flex w-full items-stretch gap-1 rounded-xl border border-red-900/80 bg-red-950/95 text-red-100 shadow-[0_8px_30px_rgba(0,0,0,0.45)] backdrop-blur-sm">
|
|
91
|
+
<p id="plexus-toast-msg" class="flex-1 px-4 py-3 text-left text-sm sm:text-base leading-snug"></p>
|
|
92
|
+
<button type="button" id="plexus-toast-close" class="shrink-0 rounded-r-xl px-3 text-lg leading-none text-red-300/90 transition-colors hover:bg-red-900/60 hover:text-white" aria-label="Dismiss error">×</button>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<script>
|
|
97
|
+
(function () {
|
|
98
|
+
var fileInput = document.getElementById('file');
|
|
99
|
+
var fileLabel = document.getElementById('fileLabel');
|
|
100
|
+
var runBtn = document.getElementById('run');
|
|
101
|
+
var toastEl = document.getElementById('plexus-toast');
|
|
102
|
+
var toastMsg = document.getElementById('plexus-toast-msg');
|
|
103
|
+
var toastClose = document.getElementById('plexus-toast-close');
|
|
104
|
+
var toastTimer = null;
|
|
105
|
+
var optFix = document.getElementById('optFix');
|
|
106
|
+
var optBundle = document.getElementById('optBundle');
|
|
107
|
+
var parsed = null;
|
|
108
|
+
|
|
109
|
+
function analyzeUrl() {
|
|
110
|
+
var meta = document.querySelector('meta[name="plexus-api-base"]');
|
|
111
|
+
if (meta && meta.content && meta.content.trim()) {
|
|
112
|
+
return meta.content.trim().replace(/\/$/, '') + '/api/analyze';
|
|
113
|
+
}
|
|
114
|
+
if (/\.github\.io$/i.test(location.hostname)) {
|
|
115
|
+
return 'https://plexus-4xdk.onrender.com/api/analyze';
|
|
116
|
+
}
|
|
117
|
+
return '/api/analyze';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function showToast(msg) {
|
|
121
|
+
toastMsg.textContent = msg;
|
|
122
|
+
toastEl.classList.remove('opacity-0', '-translate-y-2');
|
|
123
|
+
if (toastTimer) clearTimeout(toastTimer);
|
|
124
|
+
toastTimer = setTimeout(hideToast, 8000);
|
|
125
|
+
}
|
|
126
|
+
function hideToast() {
|
|
127
|
+
toastEl.classList.add('opacity-0', '-translate-y-2');
|
|
128
|
+
if (toastTimer) {
|
|
129
|
+
clearTimeout(toastTimer);
|
|
130
|
+
toastTimer = null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
toastClose.addEventListener('click', hideToast);
|
|
134
|
+
|
|
135
|
+
fileInput.addEventListener('change', function () {
|
|
136
|
+
hideToast();
|
|
137
|
+
parsed = null;
|
|
138
|
+
runBtn.disabled = true;
|
|
139
|
+
var f = fileInput.files && fileInput.files[0];
|
|
140
|
+
if (!f) {
|
|
141
|
+
fileLabel.textContent = 'Choose a package.json file';
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
fileLabel.textContent = f.name;
|
|
145
|
+
var reader = new FileReader();
|
|
146
|
+
reader.onload = function () {
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(reader.result);
|
|
149
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
150
|
+
throw new Error('JSON must be a package.json object.');
|
|
151
|
+
}
|
|
152
|
+
runBtn.disabled = false;
|
|
153
|
+
} catch (e) {
|
|
154
|
+
showToast(e.message || 'Invalid JSON.');
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
reader.readAsText(f, 'UTF-8');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
runBtn.addEventListener('click', function () {
|
|
161
|
+
if (!parsed) return;
|
|
162
|
+
hideToast();
|
|
163
|
+
runBtn.disabled = true;
|
|
164
|
+
runBtn.textContent = 'Analyzing…';
|
|
165
|
+
var pkgForApi = Object.assign({}, parsed);
|
|
166
|
+
delete pkgForApi.private;
|
|
167
|
+
delete pkgForApi.main;
|
|
168
|
+
fetch(analyzeUrl(), {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: { 'Content-Type': 'application/json' },
|
|
171
|
+
body: JSON.stringify({
|
|
172
|
+
packageJson: pkgForApi,
|
|
173
|
+
fix: optFix.checked,
|
|
174
|
+
bundleSize: optBundle.checked,
|
|
175
|
+
}),
|
|
176
|
+
})
|
|
177
|
+
.then(function (res) {
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
var ct = res.headers.get('content-type') || '';
|
|
180
|
+
if (ct.indexOf('application/json') !== -1) {
|
|
181
|
+
return res.json().then(function (j) {
|
|
182
|
+
throw new Error(j.error || res.statusText);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return res.text().then(function (t) {
|
|
186
|
+
throw new Error(t.slice(0, 200) || res.statusText);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return res.text();
|
|
190
|
+
})
|
|
191
|
+
.then(function (html) {
|
|
192
|
+
var w = window.open('', '_blank');
|
|
193
|
+
if (w) {
|
|
194
|
+
w.document.write(html);
|
|
195
|
+
w.document.close();
|
|
196
|
+
} else {
|
|
197
|
+
var blob = new Blob([html], { type: 'text/html' });
|
|
198
|
+
var url = URL.createObjectURL(blob);
|
|
199
|
+
window.location.href = url;
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
.catch(function (e) {
|
|
203
|
+
var m = e.message || String(e);
|
|
204
|
+
if (m === 'Failed to fetch' || m.indexOf('NetworkError') !== -1) {
|
|
205
|
+
m = 'Could not reach the analysis server. Check your connection and try again.';
|
|
206
|
+
}
|
|
207
|
+
showToast(m);
|
|
208
|
+
})
|
|
209
|
+
.finally(function () {
|
|
210
|
+
runBtn.disabled = false;
|
|
211
|
+
runBtn.textContent = 'Generate report';
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
})();
|
|
215
|
+
</script>
|
|
216
|
+
</body>
|
|
217
|
+
</html>
|