servicemap 0.1.28 → 0.1.31
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
|
|
6
6
|
const defaultInput = '.servicemap/internal-servicemap.json';
|
|
7
7
|
|
|
@@ -56,6 +56,7 @@ function pageCss() {
|
|
|
56
56
|
.servicemap-topbar { height: 88px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 0 28px; border-bottom: 1px solid rgba(255,255,255,0.08); background: #222823; }
|
|
57
57
|
.brand { display: flex; align-items: center; gap: 16px; min-width: 0; }
|
|
58
58
|
.brand-mark { width: 48px; height: 48px; border-radius: 10px; border: 1px solid #2a7958; background: #164d39; color: #7cf1b5; display: grid; place-items: center; box-shadow: 0 0 26px rgba(82,255,166,0.16); font-size: 22px; }
|
|
59
|
+
.brand-mark img, .hub-inner img, .three-core img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
|
59
60
|
.brand h1 { margin: 0; font-size: 20px; line-height: 1; letter-spacing: 0; }
|
|
60
61
|
.brand p, .top-meta { margin: 6px 0 0; color: #8aa097; font-size: 14px; }
|
|
61
62
|
.top-actions { display: flex; align-items: center; gap: 12px; color: #8da59a; font-size: 14px; white-space: nowrap; }
|
|
@@ -66,12 +67,16 @@ function pageCss() {
|
|
|
66
67
|
.intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
|
|
67
68
|
.intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
|
|
68
69
|
.intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
|
|
69
|
-
.edges { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
70
|
+
.edges { position: absolute; inset: 0; z-index: 26; width: 100%; height: 100%; pointer-events: none; }
|
|
71
|
+
.edge-line { transition: opacity 180ms ease, stroke 180ms ease; }
|
|
72
|
+
.edge-line.is-active { stroke: #65f0ad !important; stroke-width: 0.28; stroke-linecap: round; stroke-dasharray: 0.72 0.58; animation: servicemapDash 1.1s linear infinite; filter: drop-shadow(0 0 6px rgba(101,240,173,0.72)); opacity: 1; }
|
|
73
|
+
.edge-line.is-muted { opacity: 0.24; }
|
|
70
74
|
.hub { position: absolute; left: 50%; top: 50%; z-index: 20; width: 132px; height: 132px; transform: translate(-50%, -50%); border-radius: 24px; border: 1px solid #315c47; background: #09120d; display: grid; place-items: center; box-shadow: 0 0 0 9px rgba(79,183,128,0.12), 0 0 42px rgba(100,255,174,0.16); }
|
|
71
75
|
.hub-inner { width: 92px; height: 92px; border-radius: 18px; background: #fbfbf7; color: #111612; display: grid; place-items: center; font-size: 40px; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08); }
|
|
72
76
|
.service-node { position: absolute; z-index: 10; width: 250px; height: 74px; transform: translate(-50%, -50%); display: flex; align-items: center; gap: 16px; padding: 0 16px; border-radius: 8px; border: 1px solid #274036; background: rgba(16,24,19,0.95); box-shadow: 0 0 0 1px rgba(140,255,190,0.05), 0 18px 44px rgba(0,0,0,0.38); }
|
|
73
77
|
button.service-node { cursor: pointer; color: inherit; font: inherit; text-align: left; }
|
|
74
78
|
.service-node:hover, .service-node.is-selected { border-color: #66efae; box-shadow: 0 0 0 2px rgba(101,240,173,0.42), 0 18px 44px rgba(0,0,0,0.38); }
|
|
79
|
+
.service-node.is-selected { z-index: 27; }
|
|
75
80
|
.logo { width: 48px; height: 48px; flex: 0 0 auto; border-radius: 8px; display: grid; place-items: center; overflow: hidden; color: #fff; font-weight: 700; }
|
|
76
81
|
.logo img { width: 28px; height: 28px; object-fit: contain; filter: invert(1); }
|
|
77
82
|
.service-copy { min-width: 0; }
|
|
@@ -109,16 +114,19 @@ function pageCss() {
|
|
|
109
114
|
.delete-button { color: #ff6b57; background: transparent; font-weight: 800; }
|
|
110
115
|
.save-button { min-height: 48px; border-radius: 10px; background: #287a5a; color: #effbf5; padding: 0 18px; font-weight: 800; box-shadow: 0 10px 30px rgba(54,180,126,0.18); }
|
|
111
116
|
.helper-text { margin: 10px 0 0; color: #687c72; font-size: 12px; line-height: 1.45; }
|
|
112
|
-
.three-scene { position: absolute; inset: 0; perspective:
|
|
117
|
+
.three-scene { position: absolute; inset: 0; perspective: 1200px; overflow: hidden; cursor: grab; touch-action: none; }
|
|
113
118
|
.three-scene:active { cursor: grabbing; }
|
|
114
|
-
.three-space { position: absolute; left: 50%; top:
|
|
115
|
-
.three-link { position: absolute; left: 50%; top: 50%; width: var(--length); height: 1px; transform-origin: 0 0; transform:
|
|
116
|
-
.three-
|
|
117
|
-
.three-
|
|
118
|
-
.three-
|
|
119
|
-
.three-node
|
|
120
|
-
.three-
|
|
119
|
+
.three-space { position: absolute; left: 50%; top: 52%; width: min(1040px, 78vw); height: min(700px, 64vh); transform-style: preserve-3d; transform: translate(-50%, -50%) rotateX(var(--rx, 14deg)) rotateZ(var(--rz, 0deg)); transition: transform 140ms ease; pointer-events: none; }
|
|
120
|
+
.three-link { position: absolute; left: 50%; top: 50%; width: var(--length); height: 1px; transform-origin: 0 0; transform: rotateZ(var(--angle)); background: linear-gradient(90deg, rgba(101,240,173,0.28), rgba(101,240,173,0.05)); opacity: 0.72; }
|
|
121
|
+
.three-link.is-active { height: 2px; background: repeating-linear-gradient(90deg, #65f0ad 0 8px, transparent 8px 14px); box-shadow: 0 0 14px rgba(101,240,173,0.68); animation: servicemapLinkFlow 1s linear infinite; }
|
|
122
|
+
.three-node { position: absolute; left: 50%; top: 50%; width: 124px; min-height: 72px; transform: translate3d(var(--x), var(--y), var(--z)) translate(-50%, -50%) scale(var(--scale)); border: 0; border-radius: 999px; background: transparent; color: #eaf8f1; cursor: pointer; font: inherit; text-align: center; pointer-events: auto; }
|
|
123
|
+
.three-orb { display: grid; place-items: center; width: 30px; height: 30px; margin: 0 auto 6px; border-radius: 999px; background: var(--color); color: #fff; box-shadow: 0 0 28px color-mix(in srgb, var(--color), transparent 46%); font-size: 10px; font-weight: 900; }
|
|
124
|
+
.three-node span { display: block; max-width: 124px; margin: 0 auto; color: #e5f1ea; font-size: 10px; font-weight: 800; line-height: 1.12; text-shadow: 0 2px 8px #000; }
|
|
125
|
+
.three-node.is-selected .three-orb { outline: 2px solid #65f0ad; outline-offset: 4px; box-shadow: 0 0 34px rgba(101,240,173,0.72); }
|
|
126
|
+
.three-core { position: absolute; left: 50%; top: 50%; display: grid; place-items: center; width: 54px; height: 54px; transform: translate(-50%, -50%); border-radius: 999px; background: #f7faf7; color: #101611; font-size: 22px; box-shadow: 0 0 0 9px rgba(101,240,173,0.12), 0 0 46px rgba(101,240,173,0.2); }
|
|
121
127
|
.three-hint { position: absolute; left: 50%; bottom: 30px; z-index: 21; transform: translateX(-50%); border: 1px solid rgba(255,255,255,0.1); border-radius: 999px; background: rgba(11,20,15,0.9); color: #789086; padding: 10px 18px; font-size: 12px; }
|
|
128
|
+
@keyframes servicemapDash { to { stroke-dashoffset: -1.3; } }
|
|
129
|
+
@keyframes servicemapLinkFlow { to { background-position: 22px 0; } }
|
|
122
130
|
@media (max-width: 920px) {
|
|
123
131
|
.map { min-height: 1120px; }
|
|
124
132
|
.service-node { width: 220px; }
|
|
@@ -147,6 +155,11 @@ function initials(name) {
|
|
|
147
155
|
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
|
148
156
|
}
|
|
149
157
|
|
|
158
|
+
function projectMarkHtml(graph) {
|
|
159
|
+
const logoUrl = graph.project?.logoUrl;
|
|
160
|
+
return logoUrl ? `<img src="${escapeHtml(logoUrl)}" alt="${escapeHtml(graph.project?.name ?? 'Project')} logo">` : '⌘';
|
|
161
|
+
}
|
|
162
|
+
|
|
150
163
|
function htmlBody(graph) {
|
|
151
164
|
const services = graph.services ?? [];
|
|
152
165
|
const access = graph.access ?? {};
|
|
@@ -160,6 +173,7 @@ function htmlBody(graph) {
|
|
|
160
173
|
color: palette[index % palette.length],
|
|
161
174
|
}));
|
|
162
175
|
const edges = nodes.map((node) => `<path d="${edgePath(node)}" fill="none" stroke="rgba(103, 130, 118, 0.48)" stroke-width="0.16" vector-effect="non-scaling-stroke"></path>`).join('');
|
|
176
|
+
const projectMark = projectMarkHtml(graph);
|
|
163
177
|
const serviceNodes = nodes.map((service) => `
|
|
164
178
|
<article class="service-node" style="left:${service.x}%;top:${service.y}%">
|
|
165
179
|
<div class="logo" style="background:${service.color}">${service.iconUrl ? `<img src="${escapeHtml(service.iconUrl)}" alt="">` : escapeHtml(initials(service.name))}</div>
|
|
@@ -175,7 +189,7 @@ function htmlBody(graph) {
|
|
|
175
189
|
<main class="servicemap-shell">
|
|
176
190
|
<header class="servicemap-topbar">
|
|
177
191
|
<div class="brand">
|
|
178
|
-
<div class="brand-mark"
|
|
192
|
+
<div class="brand-mark">${projectMark}</div>
|
|
179
193
|
<div>
|
|
180
194
|
<h1>servicemap</h1>
|
|
181
195
|
<p>${escapeHtml(graph.project?.name ?? 'Project')} command center</p>
|
|
@@ -195,7 +209,7 @@ function htmlBody(graph) {
|
|
|
195
209
|
<span>Select a service to trace its dependencies</span>
|
|
196
210
|
</div>
|
|
197
211
|
<svg class="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${edges}</svg>
|
|
198
|
-
<div class="hub"><div class="hub-inner"
|
|
212
|
+
<div class="hub"><div class="hub-inner">${projectMark}</div></div>
|
|
199
213
|
${serviceNodes || '<div class="intro"><span>No external services were detected yet.</span></div>'}
|
|
200
214
|
${accessNotice}
|
|
201
215
|
<div class="legend">
|
|
@@ -240,7 +254,7 @@ type ServiceNode = RawService & { color: string; x: number; y: number };
|
|
|
240
254
|
type DraftDetails = { subtitle: string; use: string; account: string; passwordLocation: string };
|
|
241
255
|
type ServiceLink = { label: string; url: string };
|
|
242
256
|
|
|
243
|
-
const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string }; services?: RawService[] };
|
|
257
|
+
const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string; logoUrl?: string }; services?: RawService[] };
|
|
244
258
|
const palette = ${JSON.stringify(palette)} as string[];
|
|
245
259
|
const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
|
|
246
260
|
|
|
@@ -260,31 +274,58 @@ function edgePath(node: ServiceNode) {
|
|
|
260
274
|
return \`M \${fromX} \${node.y} H \${midX} V 50 H \${endX}\`;
|
|
261
275
|
}
|
|
262
276
|
|
|
263
|
-
function threePosition(index: number
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
277
|
+
function threePosition(index: number) {
|
|
278
|
+
const constellation = [
|
|
279
|
+
{ x: 0, y: -240, z: 46 },
|
|
280
|
+
{ x: 230, y: -135, z: 24 },
|
|
281
|
+
{ x: -220, y: -120, z: 10 },
|
|
282
|
+
{ x: 165, y: 88, z: 16 },
|
|
283
|
+
{ x: -170, y: 118, z: -10 },
|
|
284
|
+
{ x: 365, y: 42, z: 34 },
|
|
285
|
+
{ x: -360, y: 52, z: -18 },
|
|
286
|
+
{ x: 265, y: 250, z: -32 },
|
|
287
|
+
{ x: -265, y: -270, z: 28 },
|
|
288
|
+
{ x: 430, y: -190, z: -20 },
|
|
289
|
+
{ x: -430, y: 232, z: 22 },
|
|
290
|
+
{ x: 40, y: 312, z: -38 },
|
|
291
|
+
{ x: -60, y: -350, z: 18 },
|
|
292
|
+
{ x: 520, y: 126, z: 8 },
|
|
293
|
+
{ x: -520, y: -36, z: -26 },
|
|
294
|
+
{ x: 350, y: -330, z: 30 },
|
|
295
|
+
];
|
|
296
|
+
const fallbackAngle = index * 2.399963229728653;
|
|
297
|
+
const fallbackRadius = 250 + (index % 5) * 52;
|
|
298
|
+
const point = constellation[index] ?? {
|
|
299
|
+
x: Math.cos(fallbackAngle) * fallbackRadius,
|
|
300
|
+
y: Math.sin(fallbackAngle) * fallbackRadius * 0.72,
|
|
301
|
+
z: ((index % 7) - 3) * 14,
|
|
302
|
+
};
|
|
303
|
+
const angle = Math.atan2(point.y, point.x);
|
|
304
|
+
const length = Math.hypot(point.x, point.y);
|
|
305
|
+
const scale = 0.74 + Math.max(-36, Math.min(48, point.z)) / 220;
|
|
267
306
|
return {
|
|
268
|
-
x:
|
|
269
|
-
y:
|
|
270
|
-
z,
|
|
307
|
+
x: point.x,
|
|
308
|
+
y: point.y,
|
|
309
|
+
z: point.z,
|
|
271
310
|
angle: (angle * 180) / Math.PI,
|
|
272
|
-
length
|
|
311
|
+
length,
|
|
312
|
+
scale,
|
|
273
313
|
};
|
|
274
314
|
}
|
|
275
315
|
|
|
276
|
-
function threeNodeStyle(index: number,
|
|
277
|
-
const position = threePosition(index
|
|
316
|
+
function threeNodeStyle(index: number, color: string): CSSProperties {
|
|
317
|
+
const position = threePosition(index);
|
|
278
318
|
return {
|
|
279
319
|
'--x': \`\${position.x}px\`,
|
|
280
320
|
'--y': \`\${position.y}px\`,
|
|
281
321
|
'--z': \`\${position.z}px\`,
|
|
322
|
+
'--scale': String(position.scale),
|
|
282
323
|
'--color': color,
|
|
283
324
|
} as CSSProperties;
|
|
284
325
|
}
|
|
285
326
|
|
|
286
|
-
function threeLinkStyle(index: number
|
|
287
|
-
const position = threePosition(index
|
|
327
|
+
function threeLinkStyle(index: number): CSSProperties {
|
|
328
|
+
const position = threePosition(index);
|
|
288
329
|
return {
|
|
289
330
|
'--angle': \`\${position.angle}deg\`,
|
|
290
331
|
'--length': \`\${position.length}px\`,
|
|
@@ -325,6 +366,15 @@ function ServiceLogo({ service }: { service: RawService & { color: string } }) {
|
|
|
325
366
|
);
|
|
326
367
|
}
|
|
327
368
|
|
|
369
|
+
function ProjectMark() {
|
|
370
|
+
return graph.project?.logoUrl ? (
|
|
371
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
372
|
+
<img src={graph.project.logoUrl} alt={\`\${graph.project?.name ?? 'Project'} logo\`} />
|
|
373
|
+
) : (
|
|
374
|
+
<>⌘</>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
328
378
|
function DetailsPanel({
|
|
329
379
|
service,
|
|
330
380
|
details,
|
|
@@ -425,7 +475,7 @@ export default function InternalServiceMapPage() {
|
|
|
425
475
|
color: palette[index % palette.length],
|
|
426
476
|
})), []);
|
|
427
477
|
const [mode, setMode] = useState<'2d' | '3d'>('2d');
|
|
428
|
-
const [rotation, setRotation] = useState({ x:
|
|
478
|
+
const [rotation, setRotation] = useState({ x: 14, z: 0 });
|
|
429
479
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
430
480
|
const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
|
|
431
481
|
service.id,
|
|
@@ -443,7 +493,7 @@ export default function InternalServiceMapPage() {
|
|
|
443
493
|
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
444
494
|
<header className="servicemap-topbar">
|
|
445
495
|
<div className="brand">
|
|
446
|
-
<div className="brand-mark"
|
|
496
|
+
<div className="brand-mark"><ProjectMark /></div>
|
|
447
497
|
<div>
|
|
448
498
|
<h1>servicemap</h1>
|
|
449
499
|
<p>{graph.project?.name ?? 'Project'} command center</p>
|
|
@@ -467,10 +517,20 @@ export default function InternalServiceMapPage() {
|
|
|
467
517
|
<>
|
|
468
518
|
<svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
469
519
|
{nodes.map((service) => (
|
|
470
|
-
<path
|
|
520
|
+
<path
|
|
521
|
+
key={service.id}
|
|
522
|
+
className={\`edge-line \${selectedId === service.id ? 'is-active' : selectedId ? 'is-muted' : ''}\`}
|
|
523
|
+
d={edgePath(service)}
|
|
524
|
+
fill="none"
|
|
525
|
+
stroke={selectedId === service.id ? '#65f0ad' : 'rgba(103, 130, 118, 0.48)'}
|
|
526
|
+
strokeWidth={selectedId === service.id ? '0.28' : '0.16'}
|
|
527
|
+
strokeDasharray={selectedId === service.id ? '0.72 0.58' : undefined}
|
|
528
|
+
strokeLinecap={selectedId === service.id ? 'round' : undefined}
|
|
529
|
+
vectorEffect="non-scaling-stroke"
|
|
530
|
+
/>
|
|
471
531
|
))}
|
|
472
532
|
</svg>
|
|
473
|
-
<div className="hub"><div className="hub-inner"
|
|
533
|
+
<div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
|
|
474
534
|
{nodes.map((service) => (
|
|
475
535
|
<button
|
|
476
536
|
type="button"
|
|
@@ -497,20 +557,24 @@ export default function InternalServiceMapPage() {
|
|
|
497
557
|
<div className="three-scene" aria-label="3D service graph">
|
|
498
558
|
<div className="three-space" style={{ '--rx': \`\${rotation.x}deg\`, '--rz': \`\${rotation.z}deg\` } as CSSProperties}>
|
|
499
559
|
{nodes.map((service, index) => (
|
|
500
|
-
<span
|
|
560
|
+
<span
|
|
561
|
+
key={\`\${service.id}-link\`}
|
|
562
|
+
className={\`three-link \${selectedId === service.id ? 'is-active' : ''}\`}
|
|
563
|
+
style={threeLinkStyle(index)}
|
|
564
|
+
/>
|
|
501
565
|
))}
|
|
502
|
-
<div className="three-core"
|
|
566
|
+
<div className="three-core"><ProjectMark /></div>
|
|
503
567
|
{nodes.map((service, index) => (
|
|
504
568
|
<button
|
|
505
569
|
type="button"
|
|
506
570
|
key={service.id}
|
|
507
571
|
className={\`three-node \${selectedId === service.id ? 'is-selected' : ''}\`}
|
|
508
|
-
style={threeNodeStyle(index,
|
|
572
|
+
style={threeNodeStyle(index, service.color)}
|
|
509
573
|
onPointerMove={(event) => {
|
|
510
574
|
if (event.buttons !== 1) return;
|
|
511
575
|
setRotation((current) => ({
|
|
512
|
-
x: Math.max(
|
|
513
|
-
z: current.z + event.movementX * 0.
|
|
576
|
+
x: Math.max(-8, Math.min(34, current.x - event.movementY * 0.18)),
|
|
577
|
+
z: current.z + event.movementX * 0.16,
|
|
514
578
|
}));
|
|
515
579
|
}}
|
|
516
580
|
onClick={() => setSelectedId(service.id)}
|
|
@@ -520,7 +584,7 @@ export default function InternalServiceMapPage() {
|
|
|
520
584
|
</button>
|
|
521
585
|
))}
|
|
522
586
|
</div>
|
|
523
|
-
<div className="three-hint">Drag
|
|
587
|
+
<div className="three-hint">Drag a service to rotate / Click for details</div>
|
|
524
588
|
</div>
|
|
525
589
|
)}
|
|
526
590
|
<footer className="scan-foot">
|
|
@@ -594,9 +658,27 @@ const html = ${JSON.stringify(htmlBody(graph))};
|
|
|
594
658
|
`;
|
|
595
659
|
}
|
|
596
660
|
|
|
661
|
+
async function prepareProjectLogo(graph, outputRoot) {
|
|
662
|
+
const logoPath = graph.project?.logo?.path;
|
|
663
|
+
if (!logoPath) return;
|
|
664
|
+
|
|
665
|
+
const sourceRoot = graph.project?.root ? resolve(graph.project.root) : outputRoot;
|
|
666
|
+
const sourcePath = isAbsolute(logoPath) ? logoPath : resolve(sourceRoot, logoPath);
|
|
667
|
+
if (!existsSync(sourcePath)) return;
|
|
668
|
+
|
|
669
|
+
const extension = extname(sourcePath) || '.png';
|
|
670
|
+
const assetDir = join(outputRoot, 'public', 'internalservicemap-assets');
|
|
671
|
+
const assetPath = join(assetDir, `project-logo${extension}`);
|
|
672
|
+
await mkdir(assetDir, { recursive: true });
|
|
673
|
+
await copyFile(sourcePath, assetPath);
|
|
674
|
+
|
|
675
|
+
graph.project.logoUrl = `/internalservicemap-assets/project-logo${extension}`;
|
|
676
|
+
}
|
|
677
|
+
|
|
597
678
|
async function main() {
|
|
598
679
|
const options = parseArgs();
|
|
599
680
|
const graph = await readJson(options.input);
|
|
681
|
+
await prepareProjectLogo(graph, options.root);
|
|
600
682
|
const route = routeFor(options.root, graph.project?.framework);
|
|
601
683
|
const contents = route.kind === 'astro' ? astroPage(graph) : route.kind === 'static' ? staticHtml(graph) : tsxPage(graph);
|
|
602
684
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import { readFile, readdir,
|
|
3
|
+
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
|
|
@@ -240,6 +240,59 @@ function buildEdges(projectName, services) {
|
|
|
240
240
|
}));
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
async function findProjectLogo(root, files) {
|
|
244
|
+
const appIconManifests = files
|
|
245
|
+
.filter((file) => file.endsWith('AppIcon.appiconset/Contents.json'))
|
|
246
|
+
.sort((a, b) => scoreLogoManifest(b) - scoreLogoManifest(a));
|
|
247
|
+
|
|
248
|
+
for (const manifestPath of appIconManifests) {
|
|
249
|
+
const manifest = await readJson(manifestPath);
|
|
250
|
+
const images = Array.isArray(manifest?.images) ? manifest.images : [];
|
|
251
|
+
const candidates = images
|
|
252
|
+
.filter((image) => image?.filename)
|
|
253
|
+
.map((image) => ({
|
|
254
|
+
file: join(dirname(manifestPath), image.filename),
|
|
255
|
+
score: scoreAppIconImage(image),
|
|
256
|
+
}))
|
|
257
|
+
.filter((candidate) => existsSync(candidate.file))
|
|
258
|
+
.sort((a, b) => b.score - a.score);
|
|
259
|
+
|
|
260
|
+
if (candidates[0]) {
|
|
261
|
+
return {
|
|
262
|
+
path: relative(root, candidates[0].file),
|
|
263
|
+
source: 'asset-catalog-app-icon',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const namedLogo = files.find((file) => /(^|[/\\])(app-?icon|logo|favicon)\.(png|jpg|jpeg|webp|svg|ico)$/i.test(file));
|
|
269
|
+
if (namedLogo) {
|
|
270
|
+
return {
|
|
271
|
+
path: relative(root, namedLogo),
|
|
272
|
+
source: 'named-image-file',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function scoreLogoManifest(path) {
|
|
280
|
+
let score = 0;
|
|
281
|
+
if (/Assets\.xcassets[/\\]AppIcon\.appiconset/i.test(path)) score += 10;
|
|
282
|
+
if (/Assets 2\.xcassets/i.test(path)) score += 20;
|
|
283
|
+
return score;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function scoreAppIconImage(image) {
|
|
287
|
+
const size = Number.parseInt(String(image.size ?? '0').split('x')[0], 10) || 0;
|
|
288
|
+
let score = size;
|
|
289
|
+
const filename = String(image.filename ?? '').toLowerCase();
|
|
290
|
+
if (filename.includes('dark') || filename.includes('darker')) score += 100;
|
|
291
|
+
if (filename.includes('tinted')) score -= 50;
|
|
292
|
+
if (image.platform === 'ios') score += 25;
|
|
293
|
+
return score;
|
|
294
|
+
}
|
|
295
|
+
|
|
243
296
|
async function main() {
|
|
244
297
|
const options = parseArgs();
|
|
245
298
|
const packageJsonPath = join(options.root, 'package.json');
|
|
@@ -273,6 +326,7 @@ async function main() {
|
|
|
273
326
|
configFiles: configFiles.map((file) => basename(file)),
|
|
274
327
|
database,
|
|
275
328
|
});
|
|
329
|
+
const logo = await findProjectLogo(options.root, files);
|
|
276
330
|
|
|
277
331
|
const graph = {
|
|
278
332
|
generatedAt: new Date().toISOString(),
|
|
@@ -280,6 +334,7 @@ async function main() {
|
|
|
280
334
|
name: projectName,
|
|
281
335
|
root: options.root,
|
|
282
336
|
framework: detectFramework(options.root, packageJson, files),
|
|
337
|
+
logo,
|
|
283
338
|
},
|
|
284
339
|
access: {
|
|
285
340
|
allowedEmails: options.allowedEmails,
|