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,6 +1,6 @@
1
1
  {
2
2
  "name": "servicemap",
3
- "version": "0.1.28",
3
+ "version": "0.1.31",
4
4
  "description": "Generate an internal service map page or desktop app for a software project.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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: 920px; overflow: hidden; cursor: grab; touch-action: none; }
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: 50%; width: 620px; height: 620px; transform-style: preserve-3d; transform: translate(-50%, -50%) rotateX(var(--rx, 58deg)) rotateZ(var(--rz, -28deg)); transition: transform 140ms ease; pointer-events: none; }
115
- .three-link { position: absolute; left: 50%; top: 50%; width: var(--length); height: 1px; transform-origin: 0 0; transform: translate3d(0, 0, 0) rotateZ(var(--angle)) translateY(var(--z)); background: linear-gradient(90deg, rgba(101,240,173,0.42), rgba(101,240,173,0.05)); }
116
- .three-node { position: absolute; left: 50%; top: 50%; width: 82px; height: 82px; transform: translate3d(var(--x), var(--y), var(--z)) translate(-50%, -50%) rotateZ(28deg) rotateX(-58deg); border: 0; border-radius: 999px; background: transparent; color: #eaf8f1; cursor: pointer; font: inherit; text-align: center; pointer-events: auto; }
117
- .three-orb { display: grid; place-items: center; width: 38px; height: 38px; margin: 0 auto 7px; border-radius: 999px; background: var(--color); color: #fff; box-shadow: 0 0 28px color-mix(in srgb, var(--color), transparent 44%); font-size: 12px; font-weight: 900; }
118
- .three-node span { display: block; font-size: 10px; line-height: 1.15; text-shadow: 0 2px 8px #000; }
119
- .three-node.is-selected .three-orb { outline: 2px solid #65f0ad; outline-offset: 4px; }
120
- .three-core { position: absolute; left: 50%; top: 50%; display: grid; place-items: center; width: 72px; height: 72px; transform: translate(-50%, -50%); border-radius: 999px; background: #f7faf7; color: #101611; font-size: 30px; box-shadow: 0 0 0 10px rgba(101,240,173,0.14), 0 0 46px rgba(101,240,173,0.22); }
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">⌘</div>
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">⌘</div></div>
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, count: number) {
264
- const angle = (index / Math.max(count, 1)) * Math.PI * 2 - Math.PI / 2;
265
- const radius = index % 3 === 0 ? 270 : index % 3 === 1 ? 215 : 320;
266
- const z = ((index % 5) - 2) * 32;
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: Math.cos(angle) * radius,
269
- y: Math.sin(angle) * radius,
270
- z,
307
+ x: point.x,
308
+ y: point.y,
309
+ z: point.z,
271
310
  angle: (angle * 180) / Math.PI,
272
- length: radius,
311
+ length,
312
+ scale,
273
313
  };
274
314
  }
275
315
 
276
- function threeNodeStyle(index: number, count: number, color: string): CSSProperties {
277
- const position = threePosition(index, count);
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, count: number): CSSProperties {
287
- const position = threePosition(index, count);
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: 58, z: -28 });
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">⌘</div>
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 key={service.id} d={edgePath(service)} fill="none" stroke="rgba(103, 130, 118, 0.48)" strokeWidth="0.16" vectorEffect="non-scaling-stroke" />
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">⌘</div></div>
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 key={\`\${service.id}-link\`} className="three-link" style={threeLinkStyle(index, nodes.length)} />
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">⌘</div>
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, nodes.length, service.color)}
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(24, Math.min(72, current.x - event.movementY * 0.25)),
513
- z: current.z + event.movementX * 0.25,
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 to rotate / Scroll to zoom / Click a service</div>
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, stat, writeFile } from 'node:fs/promises';
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,