vimp-engine 0.17.0 → 0.18.1

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": "vimp-engine",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
- import { ENGINE_PACKAGE, ENGINE_VERSION } from '../../lib/engineVersion.js';
3
- import { renderPackageLink } from '../../lib/footerLink.js';
2
+ import { ENGINE_PROJECT_URL, ENGINE_VERSION } from '../../lib/engineVersion.js';
3
+ import { renderProjectLink } from '../../lib/footerLink.js';
4
4
 
5
5
  // Singleton LobbyView
6
6
 
@@ -44,7 +44,7 @@ export default class LobbyView {
44
44
  version.textContent = ENGINE_VERSION;
45
45
  }
46
46
 
47
- renderPackageLink(document.getElementById(elems.linkId), ENGINE_PACKAGE);
47
+ renderProjectLink(document.getElementById(elems.linkId), ENGINE_PROJECT_URL);
48
48
 
49
49
  // заголовок игры для "<TITLE> TOP-N" (SVG-ориентир) — задаётся controller'ом
50
50
  // при выборе игры (сама модель не хранит title манифеста)
@@ -7,13 +7,11 @@
7
7
  // бандлу (в npm-тарболле vimp-engine package.json есть всегда, поэтому
8
8
  // standalone SDK собирается у игры тем же импортом).
9
9
  import pkg from '../../../package.json' with { type: 'json' };
10
- import { homepageOf } from '../../lib/packageLink.js';
10
+ import { resolveProjectUrl } from '../../lib/packageLink.js';
11
11
 
12
12
  export const ENGINE_VERSION = pkg.version;
13
13
 
14
- // метаданные для ссылки в футере лобби — та же форма, что мастер отдаёт про
15
- // пакет игры (GameCatalog), чтобы оба футера строил один resolvePackageLink
16
- export const ENGINE_PACKAGE = {
17
- name: pkg.name,
18
- homepage: homepageOf(pkg),
19
- };
14
+ // адрес проекта для ссылки в футере лобби — то же поле, что мастер отдаёт
15
+ // про пакет игры (GameCatalog.packageUrl), чтобы оба футера строил один
16
+ // projectLink
17
+ export const ENGINE_PROJECT_URL = resolveProjectUrl(pkg);
@@ -1,21 +1,21 @@
1
- import { resolvePackageLink } from '../../lib/packageLink.js';
1
+ import { projectLink } from '../../lib/packageLink.js';
2
2
 
3
3
  // Ссылка в футере лобби и формы входа. Один рендер на оба экрана: лобби
4
- // подставляет пакет движка, форма входа — пакет игры из манифеста.
4
+ // подставляет адрес проекта движка, форма входа — адрес игры из манифеста.
5
5
  //
6
- // Данных нет (standalone-манифест без метаданных пакета) — якорь гасится, а не
6
+ // Адреса нет (пакет не объявил repository/homepage) — якорь гасится, а не
7
7
  // прячется: ячейка футера остаётся пустой, и раскладка space-between не едет.
8
8
 
9
9
  /**
10
10
  * @param {HTMLAnchorElement|null} anchor - якорь футера
11
- * @param {{name?: string, homepage?: string}|null} pkg - метаданные пакета
11
+ * @param {string|null} url - адрес проекта (resolveProjectUrl)
12
12
  */
13
- export function renderPackageLink(anchor, pkg) {
13
+ export function renderProjectLink(anchor, url) {
14
14
  if (!anchor) {
15
15
  return;
16
16
  }
17
17
 
18
- const link = resolvePackageLink(pkg);
18
+ const link = projectLink(url);
19
19
 
20
20
  if (!link) {
21
21
  anchor.textContent = '';
@@ -97,7 +97,9 @@ function buildSelect(descriptor, ctx) {
97
97
  el.value = value;
98
98
  },
99
99
  onChange(cb) {
100
- el.addEventListener('change', () => cb({ name: descriptor.name, value: el.value }));
100
+ el.addEventListener('change', () =>
101
+ cb({ name: descriptor.name, value: el.value }),
102
+ );
101
103
  },
102
104
  };
103
105
  }
@@ -110,6 +112,11 @@ function buildText(descriptor) {
110
112
  const numeric = isNumeric(descriptor);
111
113
 
112
114
  el.type = 'text';
115
+ // выпадашка прошлых значений/автозаполнения кроет форму, а подставить в
116
+ // игровое поле ей нечего. Только у текстового поля: у select такого списка
117
+ // нет, там autocomplete управлял бы лишь восстановлением значения при
118
+ // перезагрузке — а <option> создаёт JS, восстанавливать нечего
119
+ el.autocomplete = 'off';
113
120
  el.name = descriptor.name;
114
121
 
115
122
  // pattern/required — семантика контрола (a11y, автозаполнение), НЕ
@@ -157,7 +164,9 @@ function buildText(descriptor) {
157
164
  el.value = numeric ? toDisplay(descriptor, value) : (value ?? '');
158
165
  },
159
166
  onChange(cb) {
160
- el.addEventListener('change', () => cb({ name: descriptor.name, value: getValue() }));
167
+ el.addEventListener('change', () =>
168
+ cb({ name: descriptor.name, value: getValue() }),
169
+ );
161
170
  },
162
171
  };
163
172
  }
@@ -182,7 +191,9 @@ function buildCheckbox(descriptor) {
182
191
  el.checked = Boolean(value);
183
192
  },
184
193
  onChange(cb) {
185
- el.addEventListener('change', () => cb({ name: descriptor.name, value: el.checked }));
194
+ el.addEventListener('change', () =>
195
+ cb({ name: descriptor.name, value: el.checked }),
196
+ );
186
197
  },
187
198
  };
188
199
  }
@@ -239,7 +250,9 @@ function buildRadio(descriptor, ctx) {
239
250
  },
240
251
  onChange(cb) {
241
252
  inputs.forEach(input => {
242
- input.addEventListener('change', () => cb({ name: descriptor.name, value: getValue() }));
253
+ input.addEventListener('change', () =>
254
+ cb({ name: descriptor.name, value: getValue() }),
255
+ );
243
256
  });
244
257
  },
245
258
  };
@@ -458,7 +471,9 @@ export function renderFormErrors(container, errors) {
458
471
  // для них, как и раньше, остаётся имя поля
459
472
  const title = String(label || name).toUpperCase();
460
473
 
461
- line.textContent = error ? `${title}: ${error}` : `${title} is not correctly!`;
474
+ line.textContent = error
475
+ ? `${title}: ${error}`
476
+ : `${title} is not correctly!`;
462
477
 
463
478
  container.appendChild(line);
464
479
  });
@@ -556,7 +571,9 @@ export function buildForm(descriptors, container, ctx = {}, onChange) {
556
571
  } catch (e) {
557
572
  // одно поле с кривой/неизвестной схемой не должно ронять всю форму
558
573
  // (room-форма и особенно auth-форма — единственный путь к кнопке Start)
559
- console.error(`formBuilder: skipping field "${descriptor.name}" — ${e.message}`);
574
+ console.error(
575
+ `formBuilder: skipping field "${descriptor.name}" — ${e.message}`,
576
+ );
560
577
  return;
561
578
  }
562
579
 
@@ -602,7 +619,8 @@ export function buildForm(descriptors, container, ctx = {}, onChange) {
602
619
  const label = document.createElement(field.labelFor ? 'label' : 'span');
603
620
 
604
621
  label.className = 'form-label';
605
- label.textContent = (descriptor.label || descriptor.name) + buildLabelSuffix(descriptor);
622
+ label.textContent =
623
+ (descriptor.label || descriptor.name) + buildLabelSuffix(descriptor);
606
624
 
607
625
  if (field.labelFor) {
608
626
  label.htmlFor = field.labelFor;
@@ -28,7 +28,7 @@ import VoteView from './components/view/Vote.js';
28
28
  import VoteCtrl from './components/controller/Vote.js';
29
29
  import { buildForm, mergeRoomDefaults, bindLiveErrors } from './lib/formBuilder.js';
30
30
  import { normalizeAuthParams } from './lib/authParams.js';
31
- import { renderPackageLink } from './lib/footerLink.js';
31
+ import { renderProjectLink } from './lib/footerLink.js';
32
32
  import { createGameActivator } from './lib/gameActivator.js';
33
33
  import createAutostart from './lib/autostart.js';
34
34
  import { getBootConfig, resolveBootConfig } from './boot.js';
@@ -434,10 +434,10 @@ socketMethods[PS_AUTH_DATA] = data => {
434
434
  authVersion.textContent = activeGameManifest?.packageVersion ?? '';
435
435
  }
436
436
 
437
- renderPackageLink(document.getElementById('auth-package-link'), {
438
- name: activeGameManifest?.packageName,
439
- homepage: activeGameManifest?.packageHomepage,
440
- });
437
+ renderProjectLink(
438
+ document.getElementById('auth-package-link'),
439
+ activeGameManifest?.packageUrl,
440
+ );
441
441
 
442
442
  // память клиента (localStorage) + принудительное значение поля с
443
443
  // единственным вариантом. Здесь, а не в AuthView: solo-путь ниже отвечает
@@ -385,10 +385,11 @@ body.hide-cursor {
385
385
  background-color: #333;
386
386
  border: 1px solid #eee;
387
387
  padding: 10px;
388
- margin: 10px auto;
388
+ margin: 10px auto 5px;
389
389
  text-shadow: 1px 1px 1px #000;
390
390
  font-size: 12px;
391
391
  line-height: 15px;
392
+ border-radius: 4px;
392
393
  }
393
394
 
394
395
  .card h2 {
@@ -396,17 +397,19 @@ body.hide-cursor {
396
397
  line-height: 30px;
397
398
  margin: 0 0 10px 0;
398
399
  text-align: center;
400
+ border-bottom: 1px solid #444;
401
+ padding: 5px 0 10px;
399
402
  }
400
403
 
401
- /* кнопки: провайдеры логина, Create server, Start, Load more, Sign out
402
- нативный вид input[type=button], без декоративных стилей, только размер */
404
+ /* кнопки: провайдеры логина, Choose nick, Start, Load more нативный вид
405
+ input[type=button], без декоративных стилей, только размер. Create server и
406
+ Sign out сюда не входят: они лежат во флекс-контейнерах с gap и
407
+ центрируются через align-self (секция lobby-auth ниже) */
403
408
  .lobby-auth-provider,
404
409
  #lobby-auth-nick-submit,
405
- #lobby-host,
406
- #lobby-user-logout,
407
410
  #lobby-more,
408
411
  #auth-enter {
409
- margin: 4px;
412
+ margin: 15px auto;
410
413
  }
411
414
 
412
415
  /* один вертикальный ритм для всех строк формы (room-форма и auth-форма) */
@@ -415,7 +418,7 @@ body.hide-cursor {
415
418
  align-items: center;
416
419
  justify-content: space-between;
417
420
  gap: 8px;
418
- margin: 8px 0;
421
+ margin: 0;
419
422
  }
420
423
 
421
424
  .form-row input[type='text'] {
@@ -423,6 +426,10 @@ body.hide-cursor {
423
426
  text-align: center;
424
427
  }
425
428
 
429
+ #lobby-fields .form-row {
430
+ margin: 6px 0;
431
+ }
432
+
426
433
  .form-label {
427
434
  color: #aaa;
428
435
  }
@@ -459,7 +466,7 @@ body.hide-cursor {
459
466
  color: #e00;
460
467
  font-size: 12px;
461
468
  line-height: 15px;
462
- margin: 0 5px;
469
+ margin: 0 5px 10px;
463
470
  }
464
471
 
465
472
  /* пустой блок ошибок не должен занимать место: #lobby-error — флекс-элемент
@@ -470,7 +477,6 @@ body.hide-cursor {
470
477
 
471
478
  #auth-enter {
472
479
  display: block;
473
- margin: 4px auto;
474
480
  }
475
481
 
476
482
  /* футеры лобби и формы входа: одна и та же полоса из трёх ячеек —
@@ -479,18 +485,35 @@ body.hide-cursor {
479
485
  разметкой) */
480
486
  #auth-link,
481
487
  #lobby-footer {
482
- font-size: 9px;
483
488
  height: 15px;
484
- margin: 0;
485
- padding: 0 5px;
486
489
  text-shadow: none;
487
490
  display: flex;
488
- justify-content: space-between;
489
491
  }
490
492
 
493
+ #auth-link {
494
+ font-size: 9px;
495
+ margin: 0;
496
+ padding: 0 10px;
497
+ }
498
+
499
+ /* ширина — контентная область правой панели лобби (её ширина минус
500
+ собственные поля .lobby-browser-inner), под которой футер и стоит;
501
+ max-width, а не width: в одну колонку он обязан ужиматься по экрану */
502
+ #lobby-footer {
503
+ width: 100%;
504
+ max-width: calc(var(--lobby-browser-width) - 2 * var(--lobby-panel-pad));
505
+ font-size: 10px;
506
+ padding: 0;
507
+ margin: 0 var(--lobby-panel-pad) 0 0;
508
+ }
509
+
510
+ /* три равные ячейки: середину держит не space-between, а сама равность —
511
+ любая из крайних бывает пустой (у пакета игры может не быть метаданных),
512
+ и при space-between версия съезжала бы с центра */
491
513
  #auth-link p,
492
514
  #lobby-footer p {
493
515
  margin: 0;
516
+ flex: 1;
494
517
  }
495
518
 
496
519
  #auth-link p a,
@@ -498,9 +521,7 @@ body.hide-cursor {
498
521
  color: #ff0;
499
522
  }
500
523
 
501
- /* позициями заведует space-between, а не nth-child: любая из ячеек бывает
502
- пустой (у пакета игры может не быть метаданных), и порядковые правила
503
- тогда съезжали бы */
524
+ /* позиция текста внутри ячейки; сами ячейки уже разложены flex: 1 выше */
504
525
  #auth-link p:first-child,
505
526
  #lobby-footer p:first-child {
506
527
  text-align: left;
@@ -516,7 +537,11 @@ body.hide-cursor {
516
537
  text-align: right;
517
538
  }
518
539
 
519
- .auth-inform {
540
+ /* коробка контента: блок помощи формы входа и группа полей комнаты.
541
+ .lobby-card сюда не входит — ей нужен фон темнее, иначе он совпадёт с
542
+ её собственным hover'ом и наведение перестанет быть видно */
543
+ .auth-inform,
544
+ #lobby-fields {
520
545
  background-color: #444;
521
546
  padding: 7px 13px;
522
547
  margin: 10px 0;
@@ -592,6 +617,13 @@ body.hide-cursor {
592
617
  /*lobby*/
593
618
 
594
619
  #lobby {
620
+ /* ширины панелей и их внутренние поля: по ним же считается ширина футера
621
+ (секция auth выше), поэтому числа живут в одном месте — иначе смена
622
+ ширины любой панели молча разъезжает вёрстку */
623
+ --lobby-setup-width: 410px;
624
+ --lobby-browser-width: 430px;
625
+ --lobby-panel-pad: 10px;
626
+
595
627
  background-color: #222;
596
628
  position: absolute;
597
629
  width: 100%;
@@ -602,16 +634,20 @@ body.hide-cursor {
602
634
  display: flex;
603
635
  align-items: flex-start;
604
636
  justify-content: center;
605
- padding: 10px 0 0;
637
+ padding: 0;
606
638
  box-sizing: border-box;
607
639
  }
608
640
 
641
+ #lobby-user,
642
+ .lobby-section-title,
643
+ #lobby-create {
644
+ padding: 8px;
645
+ }
646
+
609
647
  #lobby-create {
610
648
  display: flex;
611
649
  flex-direction: column;
612
- gap: 8px;
613
- margin-bottom: 12px;
614
- padding: 8px;
650
+ gap: 10px;
615
651
  }
616
652
 
617
653
  /* поле имени сервера — строка формы, а не узкий числовой инпут
@@ -638,13 +674,10 @@ body.hide-cursor {
638
674
  margin: 0;
639
675
  }
640
676
 
641
- #lobby-host {
642
- margin: 16px auto 0;
643
- }
644
-
645
677
  #lobby-search {
646
678
  display: block;
647
679
  width: 100%;
680
+ margin-bottom: 10px;
648
681
  }
649
682
 
650
683
  #lobby-list {
@@ -662,9 +695,10 @@ body.hide-cursor {
662
695
  column-gap: 8px;
663
696
  row-gap: 2px;
664
697
  padding: 10px 12px;
665
- margin-bottom: 6px;
698
+ margin: 0 0 6px;
666
699
  background-color: #363636;
667
- border: 1px solid #444;
700
+ border: 1px solid #999;
701
+ border-radius: 4px;
668
702
  cursor: pointer;
669
703
  align-items: center;
670
704
  }
@@ -735,6 +769,7 @@ body.hide-cursor {
735
769
  .lobby-column {
736
770
  display: flex;
737
771
  flex-direction: column;
772
+ align-items: flex-end;
738
773
  }
739
774
 
740
775
  /* два столбца лобби (lobby-page-plan): настройки/создание слева, вкладки
@@ -744,12 +779,12 @@ body.hide-cursor {
744
779
  высоты более высокой панели */
745
780
  .lobby-grid {
746
781
  display: flex;
747
- gap: 20px;
782
+ gap: 10px;
748
783
  justify-content: center;
749
784
  }
750
785
 
751
786
  #lobby-setup-panel {
752
- width: 410px;
787
+ width: var(--lobby-setup-width);
753
788
  }
754
789
 
755
790
  /* высоту правой .card задаёт форма слева: обе панели тянутся до высоты
@@ -758,7 +793,7 @@ body.hide-cursor {
758
793
  вынесен в абсолютный слой .lobby-browser-inner (его высота не участвует в
759
794
  расчёте высоты строки) */
760
795
  #lobby-browser-panel {
761
- width: 430px;
796
+ width: var(--lobby-browser-width);
762
797
  position: relative;
763
798
  padding: 0;
764
799
  }
@@ -768,7 +803,7 @@ body.hide-cursor {
768
803
  inset: 0;
769
804
  display: flex;
770
805
  flex-direction: column;
771
- padding: 10px;
806
+ padding: var(--lobby-panel-pad);
772
807
  box-sizing: border-box;
773
808
  }
774
809
 
@@ -793,23 +828,23 @@ body.hide-cursor {
793
828
  flex-shrink: 0;
794
829
  }
795
830
 
796
- /* полосу футера рисуют общие с #auth-link правила в секции auth; здесь —
797
- только отступ от панелей */
798
- #lobby-footer {
799
- margin: 6px 0 10px;
800
- }
801
-
802
831
  @media (max-width: 800px) {
803
832
  .lobby-grid {
804
- flex-direction: column;
805
- align-items: stretch;
806
- gap: unset;
833
+ display: block;
807
834
  }
808
835
 
809
836
  /* в столбик обёртка тянется на всю ширину: иначе она сжалась бы по
810
837
  содержимому и увела бы панели из центра */
811
838
  .lobby-column {
812
839
  width: 100%;
840
+ align-items: center;
841
+ }
842
+
843
+ /* правое поле, прижимавшее футер к контенту правой панели, в одну
844
+ колонку не нужно: с ним align-items: center увёл бы его влево на
845
+ половину поля */
846
+ #lobby-footer {
847
+ margin: 0 auto;
813
848
  }
814
849
 
815
850
  /* в столбик высоту строкой никто не задаёт — возвращаем слой в поток,
@@ -2,4 +2,4 @@ div#chat
2
2
  div#chat-box
3
3
  //- maxlength — лишь UX-подсказка; авторитетный лимит на сервере
4
4
  //- (chatMaxLength в src/config/hostDefaults.js). Значения держать синхронными.
5
- input#cmd(type='text', maxlength='60' autocomplete="off")
5
+ input#cmd(type='text', maxlength='60', autocomplete='off')
@@ -21,11 +21,11 @@ div#lobby(style='display:none')
21
21
  //- селектор игры (lobby-page-plan): рабочий с первой же игрой в
22
22
  //- каталоге мастера, а не только когда их несколько
23
23
  div.form-row
24
- label.form-label(for='lobby-game')= 'Select Game'
24
+ label.form-label(for='lobby-game')= 'Select game'
25
25
  select#lobby-game.field-select
26
26
  div.form-row
27
27
  label.form-label(for='lobby-name')= 'Server name'
28
- input#lobby-name.field-text(type='text', maxlength='30')
28
+ input#lobby-name.field-text(type='text', maxlength='30', autocomplete='off')
29
29
  //- поля настроек комнаты генерируются по явной схеме roomForm
30
30
  //- манифеста активной игры (docs/en/plugin-api.md "Form schema")
31
31
  div#lobby-fields
@@ -45,7 +45,7 @@ div#lobby(style='display:none')
45
45
  input#btn-show-servers.lobby-tab-btn.active(type='button', value='Active Servers')
46
46
  input#btn-show-leaderboard.lobby-tab-btn(type='button', value='Leaderboard')
47
47
  div#lobby-servers-content
48
- input#lobby-search.field-text(type='text', placeholder='Search servers…')
48
+ input#lobby-search.field-text(type='text', placeholder='Search servers…', autocomplete='off')
49
49
  ul#lobby-list
50
50
  p#lobby-empty= 'No servers available'
51
51
  input#lobby-more(type='button', value='Load more')
@@ -14,6 +14,6 @@ div#lobby-auth
14
14
 
15
15
  div#lobby-auth-nick.card(style='display:none')
16
16
  h2= 'Choose your nick'
17
- input#lobby-auth-nick-input.field-text(type='text', maxlength='14', placeholder='YourNick')
17
+ input#lobby-auth-nick-input.field-text(type='text', maxlength='14', placeholder='YourNick', autocomplete='off')
18
18
  div#lobby-auth-nick-error
19
19
  input#lobby-auth-nick-submit(type='button', value='Continue')
@@ -0,0 +1,29 @@
1
+ import { WARN, skip, verdict } from '../result.js';
2
+ import { resolveProjectUrl } from '../../../lib/packageLink.js';
3
+
4
+ // Игра без объявленного репозитория собирается и играется — это не отказ.
5
+ // Но движок показывает ссылку на проект в футере формы входа, и берёт он её
6
+ // только отсюда (фолбэка нет, см. lib/packageLink.js): без поля ячейка
7
+ // футера у игроков останется пустой, а заметить это без подсказки негде.
8
+ // Отсюда warn — под --strict он становится ошибкой.
9
+ export default {
10
+ id: 'A7',
11
+ name: 'packageRepository',
12
+ level: WARN,
13
+ title: 'package.json declares repository (the #auth footer links to it)',
14
+
15
+ check(ctx) {
16
+ if (!ctx.pkg) {
17
+ return skip('no package.json');
18
+ }
19
+
20
+ if (resolveProjectUrl(ctx.pkg)) {
21
+ return verdict([]);
22
+ }
23
+
24
+ return verdict([
25
+ 'neither "repository" nor "homepage" resolves to a project URL — ' +
26
+ "the game's entry form will show no project link",
27
+ ]);
28
+ },
29
+ };
@@ -4,6 +4,7 @@ import a3 from './a3-entry-paths.js';
4
4
  import a4 from './a4-vite-config.js';
5
5
  import a5 from './a5-cargo-core.js';
6
6
  import a6 from './a6-manifest.js';
7
+ import a7 from './a7-package-repository.js';
7
8
  import b1 from './b1-host-shape.js';
8
9
  import b2 from './b2-engine-api.js';
9
10
  import b3 from './b3-game-config-shape.js';
@@ -34,7 +35,7 @@ import e3 from './e3-sound-registry.js';
34
35
  // Порядок групп — порядок отчёта: A пакет и сборка, B host, C client,
35
36
  // D снапшот, E ассеты (plan/create-vimp-game/stage_1.md).
36
37
  export const rules = [
37
- a1, a2, a3, a4, a5, a6,
38
+ a1, a2, a3, a4, a5, a6, a7,
38
39
  b1, b2, b3, b4, b5, b6, b7, b8, b9, b10,
39
40
  c1, c2, c3, c4, c5, c6, c7, c8, c9, c10,
40
41
  d1, d2, d3,
@@ -1,67 +1,73 @@
1
- // Ссылка на пакет для футеров лобби и формы входа: движок показывает рядом
2
- // npm-версию, и клик по соседней ячейке должен вести на страницу этого пакета.
1
+ // Ссылка на проект для футеров лобби и формы входа: движок показывает рядом
2
+ // npm-версию, и клик по соседней ячейке ведёт в репозиторий этого пакета.
3
3
  //
4
- // Источник — `homepage`/`repository` его package.json, но объявлены они не у
5
- // всех @vimp-games/snakes нет ни того, ни другого), а footer без ссылки
6
- // выглядит поломкой. Поэтому фолбэкстраница пакета на npm: она выводится из
7
- // одного лишь имени, то есть существует всегда, и сама ведёт на репозиторий,
8
- // если он объявлен.
4
+ // Источник — `repository` его package.json (иначе `homepage`). Фолбэка нет:
5
+ // пакет, не объявивший ни того, ни другого, ссылки не получает, и ячейка
6
+ // футера остаётся пустой. Это сознательно прежний фолбэк на страницу пакета
7
+ // в npm давал разнобой («GitHub» у одной игры, «npm» у другой) и молча
8
+ // маскировал недостающие метаданные вместо того, чтобы показать их отсутствие.
9
+ // О пропущенном поле предупреждает правило контракта A7.
9
10
  //
10
- // Модуль изоморфный: мастер зовёт homepageOf для пакета игры (GameCatalog),
11
- // клиент для собственного package.json движка (client/lib/engineVersion.js).
12
-
13
- const NPM_PACKAGE_BASE = 'https://www.npmjs.com/package/';
11
+ // Хостинг не ограничен github.com: подпись выводится из хоста, поэтому чужой
12
+ // плагин на GitLab получает честную ссылку, а не пустую ячейку.
13
+ //
14
+ // Модуль изоморфный: мастер зовёт resolveProjectUrl для пакета игры
15
+ // (GameCatalog), клиент — для собственного package.json движка
16
+ // (client/lib/engineVersion.js).
14
17
 
15
18
  /**
16
- * Сырой адрес проекта из package.json: homepage, иначе repository.
19
+ * Адрес проекта из package.json, приведённый к https: repository, иначе
20
+ * homepage.
17
21
  * @param {Object} pkg - разобранный package.json
18
- * @returns {string|null} адрес как объявлен (нормализует resolvePackageLink)
22
+ * @returns {string|null} https-URL либо null, если поля нет или это не адрес
19
23
  */
20
- export function homepageOf(pkg) {
24
+ export function resolveProjectUrl(pkg) {
21
25
  if (!pkg || typeof pkg !== 'object') {
22
26
  return null;
23
27
  }
24
28
 
25
29
  const { homepage, repository } = pkg;
26
30
 
27
- if (typeof homepage === 'string' && homepage.trim()) {
28
- return homepage;
29
- }
30
-
31
31
  // repository бывает и строкой ("github:user/repo"), и объектом {type, url}
32
- if (typeof repository === 'string') {
33
- return repository;
34
- }
35
-
36
- if (repository && typeof repository.url === 'string') {
37
- return repository.url;
38
- }
32
+ const declared =
33
+ typeof repository === 'string' ? repository : repository?.url;
39
34
 
40
- return null;
35
+ // объявленный, но не приводящийся к http(s) repository (пустая строка,
36
+ // внутренний git-хост) не повод терять валидный homepage: поле проверяется
37
+ // по результату, а не по факту наличия
38
+ return normalizeRepository(declared) ?? normalize(homepage);
41
39
  }
42
40
 
43
41
  /**
44
- * Ссылка футера по метаданным пакета.
45
- * @param {{name?: string, homepage?: string}} [pkg] - имя пакета и сырой homepage
46
- * @returns {{url: string, label: string}|null} null, если нет даже имени
42
+ * Ссылка футера по готовому адресу проекта.
43
+ * @param {string|null} [url] - результат resolveProjectUrl
44
+ * @returns {{url: string, label: string}|null} null, если адреса нет
47
45
  */
48
- export function resolvePackageLink(pkg) {
49
- const { name, homepage } = pkg ?? {};
50
- const declared = normalize(homepage);
46
+ export function projectLink(url) {
47
+ // адрес перепроверяется, хотя свои вызовы приходят уже из
48
+ // resolveProjectUrl: функция — публичный экспорт ("./lib/*"), а результат
49
+ // уходит прямо в href якоря (client/lib/footerLink.js)
50
+ const value = typeof url === 'string' ? url.trim() : '';
51
51
 
52
- if (declared) {
53
- return { url: declared, label: labelFor(declared) };
52
+ if (!/^https?:\/\//.test(value)) {
53
+ return null;
54
54
  }
55
55
 
56
- if (typeof name === 'string' && name.trim()) {
57
- return { url: `${NPM_PACKAGE_BASE}${name.trim()}`, label: 'npm' };
58
- }
56
+ return { url: value, label: labelFor(value) };
57
+ }
58
+
59
+ // шорткат "user/repo" — семантика поля repository (так его читает npm), в
60
+ // homepage такая строка означала бы относительный путь, а не адрес на GitHub
61
+ function normalizeRepository(raw) {
62
+ const value = typeof raw === 'string' ? raw.trim() : '';
59
63
 
60
- return null;
64
+ return /^[\w.-]+\/[\w.-]+$/.test(value)
65
+ ? `https://github.com/${value}`
66
+ : normalize(raw);
61
67
  }
62
68
 
63
69
  // приводит объявленный адрес к https-виду; всё, что после нормализации не
64
- // http(s) (git@ без известной формы, file:, мусор), не ссылка, а фолбэк
70
+ // http(s) (git@ без известной формы, file:, мусор), адресом не считается
65
71
  function normalize(raw) {
66
72
  if (typeof raw !== 'string') {
67
73
  return null;
@@ -106,6 +112,9 @@ function normalize(raw) {
106
112
 
107
113
  return (
108
114
  url
115
+ // логин (и тем более пароль) из ssh-формы адреса — не часть проекта, а
116
+ // в href он уехал бы в DOM и историю браузера
117
+ .replace(/^(https?:\/\/)[^/@]*@/, '$1')
109
118
  .replace(/\.git(?=$|[#?])/, '')
110
119
  // npm сам дописывает "#readme" в homepage при генерации package.json
111
120
  .replace(/#readme$/, '')
@@ -113,14 +122,16 @@ function normalize(raw) {
113
122
  );
114
123
  }
115
124
 
125
+ // подпись ячейки: у наших пакетов это всегда GitHub, у чужого хостинга —
126
+ // его хост, чтобы подпись не врала об источнике
116
127
  function labelFor(url) {
117
- if (/^https?:\/\/(www\.)?github\.com\//.test(url)) {
118
- return 'GitHub';
119
- }
128
+ let host;
120
129
 
121
- if (url.startsWith(NPM_PACKAGE_BASE)) {
122
- return 'npm';
130
+ try {
131
+ host = new URL(url).hostname.replace(/^www\./, '');
132
+ } catch {
133
+ return 'Website';
123
134
  }
124
135
 
125
- return 'Website';
136
+ return host === 'github.com' ? 'GitHub' : host;
126
137
  }