vimp-engine 0.17.0 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
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
  }
@@ -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;
@@ -524,6 +549,12 @@ body.hide-cursor {
524
549
  border: 1px solid #999;
525
550
  }
526
551
 
552
+ /* вертикаль внутри #lobby-create держит его gap — собственный margin
553
+ коробки удвоил бы отступ до соседей */
554
+ #lobby-fields {
555
+ margin: 0;
556
+ }
557
+
527
558
  .auth-inform h4 {
528
559
  margin: 0 0 4px 0;
529
560
  text-align: center;
@@ -592,6 +623,13 @@ body.hide-cursor {
592
623
  /*lobby*/
593
624
 
594
625
  #lobby {
626
+ /* ширины панелей и их внутренние поля: по ним же считается ширина футера
627
+ (секция auth выше), поэтому числа живут в одном месте — иначе смена
628
+ ширины любой панели молча разъезжает вёрстку */
629
+ --lobby-setup-width: 410px;
630
+ --lobby-browser-width: 430px;
631
+ --lobby-panel-pad: 10px;
632
+
595
633
  background-color: #222;
596
634
  position: absolute;
597
635
  width: 100%;
@@ -602,16 +640,20 @@ body.hide-cursor {
602
640
  display: flex;
603
641
  align-items: flex-start;
604
642
  justify-content: center;
605
- padding: 10px 0 0;
643
+ padding: 0;
606
644
  box-sizing: border-box;
607
645
  }
608
646
 
647
+ #lobby-user,
648
+ .lobby-section-title,
649
+ #lobby-create {
650
+ padding: 8px;
651
+ }
652
+
609
653
  #lobby-create {
610
654
  display: flex;
611
655
  flex-direction: column;
612
- gap: 8px;
613
- margin-bottom: 12px;
614
- padding: 8px;
656
+ gap: 10px;
615
657
  }
616
658
 
617
659
  /* поле имени сервера — строка формы, а не узкий числовой инпут
@@ -638,13 +680,10 @@ body.hide-cursor {
638
680
  margin: 0;
639
681
  }
640
682
 
641
- #lobby-host {
642
- margin: 16px auto 0;
643
- }
644
-
645
683
  #lobby-search {
646
684
  display: block;
647
685
  width: 100%;
686
+ margin-bottom: 10px;
648
687
  }
649
688
 
650
689
  #lobby-list {
@@ -662,9 +701,10 @@ body.hide-cursor {
662
701
  column-gap: 8px;
663
702
  row-gap: 2px;
664
703
  padding: 10px 12px;
665
- margin-bottom: 6px;
704
+ margin: 0 0 6px;
666
705
  background-color: #363636;
667
- border: 1px solid #444;
706
+ border: 1px solid #999;
707
+ border-radius: 4px;
668
708
  cursor: pointer;
669
709
  align-items: center;
670
710
  }
@@ -735,6 +775,7 @@ body.hide-cursor {
735
775
  .lobby-column {
736
776
  display: flex;
737
777
  flex-direction: column;
778
+ align-items: flex-end;
738
779
  }
739
780
 
740
781
  /* два столбца лобби (lobby-page-plan): настройки/создание слева, вкладки
@@ -744,12 +785,12 @@ body.hide-cursor {
744
785
  высоты более высокой панели */
745
786
  .lobby-grid {
746
787
  display: flex;
747
- gap: 20px;
788
+ gap: 10px;
748
789
  justify-content: center;
749
790
  }
750
791
 
751
792
  #lobby-setup-panel {
752
- width: 410px;
793
+ width: var(--lobby-setup-width);
753
794
  }
754
795
 
755
796
  /* высоту правой .card задаёт форма слева: обе панели тянутся до высоты
@@ -758,7 +799,7 @@ body.hide-cursor {
758
799
  вынесен в абсолютный слой .lobby-browser-inner (его высота не участвует в
759
800
  расчёте высоты строки) */
760
801
  #lobby-browser-panel {
761
- width: 430px;
802
+ width: var(--lobby-browser-width);
762
803
  position: relative;
763
804
  padding: 0;
764
805
  }
@@ -768,7 +809,7 @@ body.hide-cursor {
768
809
  inset: 0;
769
810
  display: flex;
770
811
  flex-direction: column;
771
- padding: 10px;
812
+ padding: var(--lobby-panel-pad);
772
813
  box-sizing: border-box;
773
814
  }
774
815
 
@@ -793,23 +834,23 @@ body.hide-cursor {
793
834
  flex-shrink: 0;
794
835
  }
795
836
 
796
- /* полосу футера рисуют общие с #auth-link правила в секции auth; здесь —
797
- только отступ от панелей */
798
- #lobby-footer {
799
- margin: 6px 0 10px;
800
- }
801
-
802
837
  @media (max-width: 800px) {
803
838
  .lobby-grid {
804
- flex-direction: column;
805
- align-items: stretch;
806
- gap: unset;
839
+ display: block;
807
840
  }
808
841
 
809
842
  /* в столбик обёртка тянется на всю ширину: иначе она сжалась бы по
810
843
  содержимому и увела бы панели из центра */
811
844
  .lobby-column {
812
845
  width: 100%;
846
+ align-items: center;
847
+ }
848
+
849
+ /* правое поле, прижимавшее футер к контенту правой панели, в одну
850
+ колонку не нужно: с ним align-items: center увёл бы его влево на
851
+ половину поля */
852
+ #lobby-footer {
853
+ margin: 0 auto;
813
854
  }
814
855
 
815
856
  /* в столбик высоту строкой никто не задаёт — возвращаем слой в поток,
@@ -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,55 @@
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) ?? homepage;
39
34
 
40
- return null;
35
+ return normalize(declared);
41
36
  }
42
37
 
43
38
  /**
44
- * Ссылка футера по метаданным пакета.
45
- * @param {{name?: string, homepage?: string}} [pkg] - имя пакета и сырой homepage
46
- * @returns {{url: string, label: string}|null} null, если нет даже имени
39
+ * Ссылка футера по готовому адресу проекта.
40
+ * @param {string|null} [url] - результат resolveProjectUrl
41
+ * @returns {{url: string, label: string}|null} null, если адреса нет
47
42
  */
48
- export function resolvePackageLink(pkg) {
49
- const { name, homepage } = pkg ?? {};
50
- const declared = normalize(homepage);
51
-
52
- if (declared) {
53
- return { url: declared, label: labelFor(declared) };
54
- }
55
-
56
- if (typeof name === 'string' && name.trim()) {
57
- return { url: `${NPM_PACKAGE_BASE}${name.trim()}`, label: 'npm' };
43
+ export function projectLink(url) {
44
+ if (typeof url !== 'string' || !url.trim()) {
45
+ return null;
58
46
  }
59
47
 
60
- return null;
48
+ return { url, label: labelFor(url) };
61
49
  }
62
50
 
63
51
  // приводит объявленный адрес к https-виду; всё, что после нормализации не
64
- // http(s) (git@ без известной формы, file:, мусор), не ссылка, а фолбэк
52
+ // http(s) (git@ без известной формы, file:, мусор), адресом не считается
65
53
  function normalize(raw) {
66
54
  if (typeof raw !== 'string') {
67
55
  return null;
@@ -73,7 +61,14 @@ function normalize(raw) {
73
61
  return null;
74
62
  }
75
63
 
76
- // npm-шорткаты: "github:user/repo", "gitlab:...", "bitbucket:..."
64
+ // npm-шорткаты: "github:user/repo", "gitlab:...", "bitbucket:...", а также
65
+ // голое "user/repo" — его принимает флаг --repository скаффолдера
66
+ const bare = /^([\w.-]+\/[\w.-]+)$/.exec(url);
67
+
68
+ if (bare) {
69
+ return `https://github.com/${bare[1]}`;
70
+ }
71
+
77
72
  const shorthand = /^(github|gitlab|bitbucket):([\w.-]+\/[\w.-]+)$/.exec(url);
78
73
 
79
74
  if (shorthand) {
@@ -113,14 +108,14 @@ function normalize(raw) {
113
108
  );
114
109
  }
115
110
 
111
+ // подпись ячейки: у наших пакетов это всегда GitHub, у чужого хостинга —
112
+ // его хост, чтобы подпись не врала об источнике
116
113
  function labelFor(url) {
117
114
  if (/^https?:\/\/(www\.)?github\.com\//.test(url)) {
118
115
  return 'GitHub';
119
116
  }
120
117
 
121
- if (url.startsWith(NPM_PACKAGE_BASE)) {
122
- return 'npm';
123
- }
118
+ const host = /^https?:\/\/(?:www\.)?([^/:]+)/.exec(url);
124
119
 
125
- return 'Website';
120
+ return host ? host[1] : 'Website';
126
121
  }