react-cheminfo 0.41.0 → 0.43.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.
Files changed (88) hide show
  1. package/bin/check-deploy.mjs +3 -1
  2. package/lib/chrome/ui/AboutButton.d.ts +59 -0
  3. package/lib/chrome/ui/AboutButton.d.ts.map +1 -0
  4. package/lib/chrome/ui/AboutButton.js +29 -0
  5. package/lib/chrome/ui/AboutButton.js.map +1 -0
  6. package/lib/chrome/ui/index.d.ts +2 -0
  7. package/lib/chrome/ui/index.d.ts.map +1 -1
  8. package/lib/chrome/ui/index.js +1 -0
  9. package/lib/chrome/ui/index.js.map +1 -1
  10. package/lib/deploy/core/buildGate.d.ts.map +1 -1
  11. package/lib/deploy/core/buildGate.js +4 -34
  12. package/lib/deploy/core/buildGate.js.map +1 -1
  13. package/lib/deploy/core/checkDeploy.d.ts.map +1 -1
  14. package/lib/deploy/core/checkDeploy.js +2 -0
  15. package/lib/deploy/core/checkDeploy.js.map +1 -1
  16. package/lib/deploy/core/index.d.ts +1 -0
  17. package/lib/deploy/core/index.d.ts.map +1 -1
  18. package/lib/deploy/core/index.js +1 -0
  19. package/lib/deploy/core/index.js.map +1 -1
  20. package/lib/deploy/core/json.d.ts +27 -0
  21. package/lib/deploy/core/json.d.ts.map +1 -0
  22. package/lib/deploy/core/json.js +56 -0
  23. package/lib/deploy/core/json.js.map +1 -0
  24. package/lib/deploy/core/reactCopies.d.ts +32 -0
  25. package/lib/deploy/core/reactCopies.d.ts.map +1 -0
  26. package/lib/deploy/core/reactCopies.js +212 -0
  27. package/lib/deploy/core/reactCopies.js.map +1 -0
  28. package/lib/deploy/core/types.d.ts +1 -1
  29. package/lib/deploy/core/types.d.ts.map +1 -1
  30. package/lib/deploy/core/types.js.map +1 -1
  31. package/lib/ecosystem/core/sites.d.ts.map +1 -1
  32. package/lib/ecosystem/core/sites.js +12 -0
  33. package/lib/ecosystem/core/sites.js.map +1 -1
  34. package/lib/ecosystem/core/types.d.ts +1 -1
  35. package/lib/ecosystem/core/types.d.ts.map +1 -1
  36. package/lib/ecosystem/ui/glyphs.d.ts.map +1 -1
  37. package/lib/ecosystem/ui/glyphs.js +4 -0
  38. package/lib/ecosystem/ui/glyphs.js.map +1 -1
  39. package/lib/i18n/core/chromeCatalog.d.ts +1 -1
  40. package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
  41. package/lib/locales/de.json +2 -0
  42. package/lib/locales/en.json +2 -0
  43. package/lib/locales/es.json +2 -0
  44. package/lib/locales/fr.json +2 -0
  45. package/lib/locales/it.json +2 -0
  46. package/lib/periodic/ui/ElementCell.d.ts +22 -2
  47. package/lib/periodic/ui/ElementCell.d.ts.map +1 -1
  48. package/lib/periodic/ui/ElementCell.js +20 -145
  49. package/lib/periodic/ui/ElementCell.js.map +1 -1
  50. package/lib/periodic/ui/PeriodicTable.d.ts +7 -0
  51. package/lib/periodic/ui/PeriodicTable.d.ts.map +1 -1
  52. package/lib/periodic/ui/PeriodicTable.js +21 -3
  53. package/lib/periodic/ui/PeriodicTable.js.map +1 -1
  54. package/lib/periodic/ui/cellStyles.d.ts +79 -0
  55. package/lib/periodic/ui/cellStyles.d.ts.map +1 -0
  56. package/lib/periodic/ui/cellStyles.js +90 -0
  57. package/lib/periodic/ui/cellStyles.js.map +1 -0
  58. package/lib/periodic/ui/cellType.d.ts +106 -0
  59. package/lib/periodic/ui/cellType.d.ts.map +1 -0
  60. package/lib/periodic/ui/cellType.js +181 -0
  61. package/lib/periodic/ui/cellType.js.map +1 -0
  62. package/lib/periodic/ui/glyphWidths.d.ts +11 -0
  63. package/lib/periodic/ui/glyphWidths.d.ts.map +1 -0
  64. package/lib/periodic/ui/glyphWidths.js +69 -0
  65. package/lib/periodic/ui/glyphWidths.js.map +1 -0
  66. package/package.json +9 -1
  67. package/src/chrome/ui/AboutButton.tsx +90 -0
  68. package/src/chrome/ui/index.ts +2 -0
  69. package/src/deploy/core/buildGate.ts +4 -35
  70. package/src/deploy/core/checkDeploy.ts +7 -0
  71. package/src/deploy/core/index.ts +1 -0
  72. package/src/deploy/core/json.ts +53 -0
  73. package/src/deploy/core/reactCopies.ts +243 -0
  74. package/src/deploy/core/types.ts +5 -1
  75. package/src/ecosystem/core/sites.ts +13 -0
  76. package/src/ecosystem/core/types.ts +2 -1
  77. package/src/ecosystem/ui/glyphs.tsx +16 -0
  78. package/src/locales/de.json +2 -0
  79. package/src/locales/en.json +2 -0
  80. package/src/locales/es.json +2 -0
  81. package/src/locales/fr.json +2 -0
  82. package/src/locales/it.json +2 -0
  83. package/src/periodic/ui/ElementCell.tsx +69 -160
  84. package/src/periodic/ui/PeriodicTable.tsx +37 -2
  85. package/src/periodic/ui/cellStyles.ts +100 -0
  86. package/src/periodic/ui/cellType.ts +223 -0
  87. package/src/periodic/ui/glyphWidths.ts +68 -0
  88. package/styles/chrome.css +10 -2
@@ -0,0 +1,69 @@
1
+ /**
2
+ * What each letter advances, in ems, in the family's type.
3
+ *
4
+ * Read off the rendered page at 100px, rounded up, because sorting a glyph into
5
+ * one of four width classes puts `Tm` an eighth wider than it is — which is a
6
+ * symbol written an eighth smaller than its cell can take. A platform
7
+ * substituting another face moves these by a few per cent, which the safety
8
+ * margin of `cellType` covers.
9
+ */
10
+ export const ADVANCE = {
11
+ a: 0.5,
12
+ b: 0.57,
13
+ c: 0.52,
14
+ d: 0.57,
15
+ e: 0.52,
16
+ f: 0.33,
17
+ g: 0.56,
18
+ h: 0.54,
19
+ i: 0.22,
20
+ j: 0.22,
21
+ k: 0.5,
22
+ l: 0.23,
23
+ m: 0.8,
24
+ n: 0.54,
25
+ o: 0.54,
26
+ p: 0.56,
27
+ q: 0.56,
28
+ r: 0.35,
29
+ s: 0.47,
30
+ t: 0.33,
31
+ u: 0.54,
32
+ v: 0.5,
33
+ w: 0.72,
34
+ x: 0.48,
35
+ y: 0.5,
36
+ z: 0.5,
37
+ A: 0.62,
38
+ B: 0.6,
39
+ C: 0.66,
40
+ D: 0.67,
41
+ E: 0.55,
42
+ F: 0.53,
43
+ G: 0.69,
44
+ H: 0.69,
45
+ I: 0.24,
46
+ J: 0.49,
47
+ K: 0.6,
48
+ L: 0.52,
49
+ M: 0.81,
50
+ N: 0.69,
51
+ O: 0.71,
52
+ P: 0.59,
53
+ Q: 0.71,
54
+ R: 0.6,
55
+ S: 0.59,
56
+ T: 0.59,
57
+ U: 0.68,
58
+ V: 0.62,
59
+ W: 0.9,
60
+ X: 0.62,
61
+ Y: 0.6,
62
+ Z: 0.61,
63
+ ' ': 0.27,
64
+ '-': 0.33,
65
+ '–': 0.54,
66
+ '.': 0.25,
67
+ ',': 0.25,
68
+ };
69
+ //# sourceMappingURL=glyphWidths.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"glyphWidths.js","sourceRoot":"","sources":["../../../src/periodic/ui/glyphWidths.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,OAAO,GAA2B;IAC7C,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,IAAI;IACP,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,IAAI;IACP,GAAG,EAAE,IAAI;IACT,GAAG,EAAE,IAAI;IACT,GAAG,EAAE,IAAI;IACT,GAAG,EAAE,IAAI;IACT,GAAG,EAAE,IAAI;CACV,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-cheminfo",
3
- "version": "0.41.0",
3
+ "version": "0.43.0",
4
4
  "license": "MIT",
5
5
  "description": "React components, hooks and framework-free helpers shared by the cheminfo sites: site chrome, citations, sharing, routing, pedagogy, charts, and the structure, orbital and 3D molecule viewers",
6
6
  "author": "Luc Patiny",
@@ -76,6 +76,7 @@
76
76
  "react-markdown": "^10.1.0",
77
77
  "rehype-raw": "^7.0.0",
78
78
  "rehype-sanitize": "^6.0.0",
79
+ "semver": "^7.8.5",
79
80
  "yaml": "^2.9.1"
80
81
  },
81
82
  "devDependencies": {
@@ -86,6 +87,7 @@
86
87
  "@types/node": "^26.2.0",
87
88
  "@types/react": "^19.2.18",
88
89
  "@types/react-dom": "^19.2.4",
90
+ "@types/semver": "^7.8.0",
89
91
  "@vitest/coverage-v8": "^4.1.10",
90
92
  "@zakodium/tsconfig": "^1.0.5",
91
93
  "benchmark": "^2.1.4",
@@ -154,5 +156,11 @@
154
156
  "xtb-wasm": {
155
157
  "optional": true
156
158
  }
159
+ },
160
+ "overrides": {
161
+ "react": "^19.2.8",
162
+ "react-dom": "^19.2.8",
163
+ "@types/react": "^19.2.18",
164
+ "@types/react-dom": "^19.2.4"
157
165
  }
158
166
  }
@@ -0,0 +1,90 @@
1
+ import type { ReactElement } from 'react';
2
+
3
+ import { useChromeT } from '../../i18n/ui/useT.ts';
4
+
5
+ import { NavLink } from './NavLink.tsx';
6
+
7
+ /** What the About entry of a header needs. */
8
+ export interface AboutButtonProps {
9
+ /**
10
+ * The site's About address, which the entry opens. It is written rather than
11
+ * assumed, because a deployment may sit under a mount path and a translated
12
+ * site under a language prefix.
13
+ */
14
+ href: string;
15
+ /**
16
+ * Whether the About is the page on show, which is what the brand tint is
17
+ * spent on.
18
+ * @default false
19
+ */
20
+ active?: boolean;
21
+ /**
22
+ * What the site does when the entry is picked, for a page that routes in
23
+ * place. A modified click is left to the browser.
24
+ * @default undefined
25
+ */
26
+ onSelect?: () => void;
27
+ /**
28
+ * What the entry reads. In a compact bar it is not written, but it stays what
29
+ * the pointer and a screen reader are told.
30
+ * @default the chrome's own word for it, in the language of the page
31
+ */
32
+ label?: string;
33
+ /**
34
+ * What the pointer is told.
35
+ * @default the chrome's own sentence, in the language of the page
36
+ */
37
+ title?: string;
38
+ /**
39
+ * What the active entry is named by, for a site whose router spells its
40
+ * addresses rather than its tabs.
41
+ * @default 'about'
42
+ */
43
+ id?: string;
44
+ /**
45
+ * Class names added after the component's own.
46
+ * @default undefined
47
+ */
48
+ className?: string;
49
+ }
50
+
51
+ /**
52
+ * The About entry, which leads the utilities on every site of the family: a
53
+ * real address rather than a dialog, so the page is indexed, linkable and
54
+ * printable.
55
+ *
56
+ * The glyph is `info-sign` on every site, and is not a site's to choose. It is
57
+ * what the entry keeps once a narrow bar drops the labels, and the site's own
58
+ * mark already stands at the other end of the same bar — drawn twice, nothing
59
+ * in the row says which one is the site and which one is about it.
60
+ * @param props - Where the About is, whether it is on show, and how the site
61
+ * opens it.
62
+ * @returns The entry.
63
+ */
64
+ export function AboutButton(props: AboutButtonProps): ReactElement {
65
+ const {
66
+ href,
67
+ active = false,
68
+ onSelect,
69
+ label,
70
+ title,
71
+ id = 'about',
72
+ className,
73
+ } = props;
74
+ const t = useChromeT();
75
+
76
+ return (
77
+ <NavLink
78
+ item={{
79
+ id,
80
+ label: label ?? t('chrome.about'),
81
+ icon: 'info-sign',
82
+ href,
83
+ title: title ?? t('chrome.aboutTitle'),
84
+ onSelect,
85
+ }}
86
+ active={active}
87
+ className={className}
88
+ />
89
+ );
90
+ }
@@ -1,3 +1,5 @@
1
+ export type { AboutButtonProps } from './AboutButton.tsx';
2
+ export { AboutButton } from './AboutButton.tsx';
1
3
  export type { AccountButtonProps, AccountIdentity } from './AccountButton.tsx';
2
4
  export { AccountButton } from './AccountButton.tsx';
3
5
  export type { HeaderToggleOption, HeaderToggleProps } from './HeaderToggle.tsx';
@@ -1,8 +1,6 @@
1
+ import { lineOf, parseJsonObject, stringEntries } from './json.ts';
1
2
  import type { DeployProblem } from './types.ts';
2
3
 
3
- /** The scripts a repository's own `package.json` declares, as npm would run them. */
4
- type Scripts = Record<string, string>;
5
-
6
4
  /**
7
5
  * Report a repository that can bundle, and so publish, code nothing tested.
8
6
  *
@@ -24,8 +22,9 @@ export function readBuildGate(
24
22
  problems: DeployProblem[],
25
23
  ): void {
26
24
  if (packageJson === undefined) return;
27
- const scripts = parseScripts(packageJson);
28
- if (scripts === undefined) return;
25
+ const parsed = parseJsonObject(packageJson);
26
+ if (parsed === undefined) return;
27
+ const scripts = stringEntries(parsed.scripts);
29
28
 
30
29
  const build = scripts.build;
31
30
  if (build !== undefined && !/\bnpm run test\b/.test(build)) {
@@ -90,33 +89,3 @@ function gatedBuildLine(dockerfile: string): number | undefined {
90
89
  }
91
90
  return undefined;
92
91
  }
93
-
94
- /**
95
- * The scripts block of a `package.json`.
96
- * @param text - The file, unparsed.
97
- * @returns Its scripts, or undefined when the file is not readable as one.
98
- */
99
- function parseScripts(text: string): Scripts | undefined {
100
- let parsed: unknown;
101
- try {
102
- parsed = JSON.parse(text);
103
- } catch {
104
- return undefined;
105
- }
106
- if (typeof parsed !== 'object' || parsed === null) return undefined;
107
- const scripts = (parsed as { scripts?: unknown }).scripts;
108
- if (typeof scripts !== 'object' || scripts === null) return {};
109
- return scripts as Scripts;
110
- }
111
-
112
- /**
113
- * Where a report should point, so the reader lands on the script itself.
114
- * @param text - The file to search.
115
- * @param needle - The key to find.
116
- * @returns Its line, counting from 1, or 0 when it is not there.
117
- */
118
- function lineOf(text: string, needle: string): number {
119
- const at = text.indexOf(needle);
120
- if (at === -1) return 0;
121
- return text.slice(0, at).split('\n').length;
122
- }
@@ -7,6 +7,7 @@ import {
7
7
  readInstallRetries,
8
8
  readPublishedImage,
9
9
  } from './publishedImage.ts';
10
+ import { readReactCopies } from './reactCopies.ts';
10
11
  import type { CheckDeployOptions, DeployFile, DeployProblem } from './types.ts';
11
12
  import { DEPLOY_COMPOSE_FILES } from './types.ts';
12
13
 
@@ -88,6 +89,12 @@ export function findDeployProblems(
88
89
 
89
90
  readBuildGate(byPath.get('package.json'), dockerfile, problems);
90
91
 
92
+ readReactCopies(
93
+ byPath.get('package.json'),
94
+ byPath.get('package-lock.json'),
95
+ problems,
96
+ );
97
+
91
98
  readPublishedImage(byPath.get(DOCKER_IMAGE_WORKFLOW), problems);
92
99
 
93
100
  const gitignore = byPath.get('.gitignore');
@@ -7,6 +7,7 @@ export type {
7
7
  DeployProblemKind,
8
8
  } from './types.ts';
9
9
  export { NGINX_CONFIG, PAGE_CACHE_CONFIG } from './pageCache.ts';
10
+ export { REACT_MAJOR, readReactCopies } from './reactCopies.ts';
10
11
  export {
11
12
  DOCKER_IMAGE_WORKFLOW,
12
13
  readInstallRetries,
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Reading the repository's own JSON files, which a checker must do without ever
3
+ * throwing: the absence of a file, and a file somebody has broken, are both
4
+ * things to report rather than to crash on.
5
+ */
6
+
7
+ /**
8
+ * A JSON file the repository may not have, or may have broken.
9
+ * @param text - The file, unparsed.
10
+ * @returns Its object form, or undefined when it is neither.
11
+ */
12
+ export function parseJsonObject(
13
+ text: string | undefined,
14
+ ): Record<string, unknown> | undefined {
15
+ if (text === undefined) return undefined;
16
+ let parsed: unknown;
17
+ try {
18
+ parsed = JSON.parse(text);
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ if (typeof parsed !== 'object' || parsed === null) return undefined;
23
+ return parsed as Record<string, unknown>;
24
+ }
25
+
26
+ /**
27
+ * A block of a `package.json` as a map of strings, dropping any entry that is
28
+ * not one — the nested form of `overrides` pins something other than the
29
+ * package it is written under.
30
+ * @param value - The block, as parsed.
31
+ * @returns Its string entries.
32
+ */
33
+ export function stringEntries(value: unknown): Record<string, string> {
34
+ if (typeof value !== 'object' || value === null) return {};
35
+ const found: Record<string, string> = {};
36
+ for (const [key, entry] of Object.entries(value)) {
37
+ if (typeof entry === 'string') found[key] = entry;
38
+ }
39
+ return found;
40
+ }
41
+
42
+ /**
43
+ * Where a report should point, so the reader lands on the line itself.
44
+ * @param text - The file to search, when there is one.
45
+ * @param needle - The literal to find.
46
+ * @returns Its line, counting from 1, or 0 when it is not there.
47
+ */
48
+ export function lineOf(text: string | undefined, needle: string): number {
49
+ if (text === undefined) return 0;
50
+ const at = text.indexOf(needle);
51
+ if (at === -1) return 0;
52
+ return text.slice(0, at).split('\n').length;
53
+ }
@@ -0,0 +1,243 @@
1
+ import semver from 'semver';
2
+
3
+ import { lineOf, parseJsonObject, stringEntries } from './json.ts';
4
+ import type { DeployProblem } from './types.ts';
5
+
6
+ /**
7
+ * The React major the whole family runs. One number, in one place: a site that
8
+ * resolves anything else is reporting a resolution nobody asked for.
9
+ */
10
+ export const REACT_MAJOR = 19;
11
+
12
+ /**
13
+ * The packages that must resolve to exactly one copy. The two runtime ones
14
+ * because two copies are a blank page; the two type packages because two copies
15
+ * are a type error in a file nobody edited.
16
+ */
17
+ const SINGLE_COPY = [
18
+ 'react',
19
+ 'react-dom',
20
+ '@types/react',
21
+ '@types/react-dom',
22
+ ] as const;
23
+
24
+ /** One package as the lock file resolved it. */
25
+ interface Resolved {
26
+ /** Where npm put it, as the lock keys it. */
27
+ path: string;
28
+ /** The exact version it resolved to. */
29
+ version: string;
30
+ }
31
+
32
+ /** What the root `package.json` says about a package. */
33
+ interface Declared {
34
+ /** The range a direct dependency asks for, if it is one. */
35
+ dependency?: string;
36
+ /** The range the `overrides` block pins, if it pins one. */
37
+ override?: string;
38
+ }
39
+
40
+ /**
41
+ * Report every way a repository would resolve React twice, or resolve a React
42
+ * that is not the family's.
43
+ *
44
+ * Two physical copies of `react` make every component from the offending
45
+ * subtree call hooks against a dispatcher the active renderer never populated,
46
+ * and the app dies at first render on `Cannot read properties of null (reading
47
+ * 'useEffect')` — a blank page with the correct title and the correct CSS,
48
+ * which reads like a routing or a build bug and is neither. Two copies of
49
+ * `@types/react` are the same mistake one layer up: `tsc` rejects a component
50
+ * crossing between them, in a file nobody touched.
51
+ *
52
+ * The cause is almost never a direct dependency. It is a *transitive* peer
53
+ * range that stopped before the current major — `@blueprintjs/core` depends on
54
+ * `react-popper@2`, whose peers stop at `^18` — so npm honours the stale range,
55
+ * installs an older React for that subtree and hoists it. Nothing fails at
56
+ * install, nothing fails at build, and the bundle silently grows by a second
57
+ * copy of React. The fix is a pin in the root `overrides`, which is why a lock
58
+ * holding such a peer without one is reported even while the tree happens to be
59
+ * clean today: it is one fresh resolve away from not being.
60
+ * @param packageJson - The repository's root `package.json`, unparsed.
61
+ * @param packageLock - Its `package-lock.json`, unparsed.
62
+ * @param problems - Where each problem found is appended.
63
+ */
64
+ export function readReactCopies(
65
+ packageJson: string | undefined,
66
+ packageLock: string | undefined,
67
+ problems: DeployProblem[],
68
+ ): void {
69
+ const lock = parseJsonObject(packageLock);
70
+ if (lock === undefined) return;
71
+ const packages = lock.packages;
72
+ if (typeof packages !== 'object' || packages === null) return;
73
+ const entries = Object.entries(packages as Record<string, unknown>);
74
+
75
+ const resolved = resolvedCopies(entries);
76
+ // A repository with no React at all is not a React repository.
77
+ if (resolved.get('react')?.length === undefined) return;
78
+
79
+ const declared = declarations(packageJson);
80
+
81
+ for (const name of SINGLE_COPY) {
82
+ const copies = resolved.get(name);
83
+ if (copies === undefined || copies.length === 0) continue;
84
+ if (copies.length > 1) {
85
+ problems.push({
86
+ file: 'package-lock.json',
87
+ line: lineOf(packageLock, `"${copies[1]?.path ?? name}"`),
88
+ kind: 'duplicate-react',
89
+ message: `${name} resolves to ${copies.length} copies: ${copies
90
+ .map((copy) => `${copy.version} at ${copy.path}`)
91
+ .join(', ')}`,
92
+ hint: `pin ${name} in the root overrides, then reinstall from scratch — npm install alone keeps the stale tree on disk`,
93
+ });
94
+ }
95
+ for (const copy of copies) {
96
+ const major = semver.major(semver.coerce(copy.version) ?? '0.0.0');
97
+ if (major !== REACT_MAJOR) {
98
+ problems.push({
99
+ file: 'package-lock.json',
100
+ line: lineOf(packageLock, `"${copy.path}"`),
101
+ kind: 'stray-react-major',
102
+ message: `${name} resolves to ${copy.version} at ${copy.path}, and the family runs React ${REACT_MAJOR}`,
103
+ hint: `declare ^${REACT_MAJOR} and pin it in the root overrides`,
104
+ });
105
+ }
106
+ }
107
+ }
108
+
109
+ const stale = stalePeer(entries);
110
+ if (stale !== undefined) {
111
+ for (const name of ['react', 'react-dom'] as const) {
112
+ if (declared.get(name)?.override !== undefined) continue;
113
+ problems.push({
114
+ file: 'package.json',
115
+ line: lineOf(packageJson, '"dependencies"'),
116
+ kind: 'unpinned-react',
117
+ message: `${stale.name} asks for ${stale.key} ${stale.range}, which excludes React ${REACT_MAJOR}, and nothing pins ${name}`,
118
+ hint: `add "overrides": { "react": "^${REACT_MAJOR}…", "react-dom": "^${REACT_MAJOR}…" } to the root package.json`,
119
+ });
120
+ }
121
+ }
122
+
123
+ for (const name of SINGLE_COPY) {
124
+ const entry = declared.get(name);
125
+ if (entry?.override === undefined || entry.dependency === undefined) {
126
+ continue;
127
+ }
128
+ if (entry.override !== entry.dependency) {
129
+ problems.push({
130
+ file: 'package.json',
131
+ line: lineOf(packageJson, `"${name}"`),
132
+ kind: 'conflicting-react-pin',
133
+ message: `${name} is pinned to ${entry.override} while it is depended on as ${entry.dependency}, so npm install fails with EOVERRIDE`,
134
+ hint: `write the same range in both: the override and the dependency are one decision`,
135
+ });
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Every real copy of each tracked package, in lock order. A workspace link is
142
+ * not a copy — it is the repository pointing at itself.
143
+ * @param entries - The lock's `packages`, as entries.
144
+ * @returns The copies found, keyed by package name.
145
+ */
146
+ function resolvedCopies(
147
+ entries: ReadonlyArray<[string, unknown]>,
148
+ ): Map<string, Resolved[]> {
149
+ const found = new Map<string, Resolved[]>();
150
+ for (const [path, value] of entries) {
151
+ if (typeof value !== 'object' || value === null) continue;
152
+ const entry = value as { version?: unknown; link?: unknown };
153
+ if (entry.link === true) continue;
154
+ if (typeof entry.version !== 'string') continue;
155
+ for (const name of SINGLE_COPY) {
156
+ if (
157
+ path !== `node_modules/${name}` &&
158
+ !path.endsWith(`/node_modules/${name}`)
159
+ ) {
160
+ continue;
161
+ }
162
+ const copies = found.get(name) ?? [];
163
+ copies.push({ path, version: entry.version });
164
+ found.set(name, copies);
165
+ }
166
+ }
167
+ return found;
168
+ }
169
+
170
+ /**
171
+ * The first package in the lock whose React peer stopped before the family's
172
+ * major — the thing that makes npm resolve a second copy.
173
+ * @param entries - The lock's `packages`, as entries.
174
+ * @returns What asks for what, or undefined when every peer admits the major.
175
+ */
176
+ function stalePeer(
177
+ entries: ReadonlyArray<[string, unknown]>,
178
+ ): { name: string; key: string; range: string } | undefined {
179
+ for (const [path, value] of entries) {
180
+ if (typeof value !== 'object' || value === null) continue;
181
+ const entry = value as {
182
+ peerDependencies?: unknown;
183
+ peerDependenciesMeta?: unknown;
184
+ };
185
+ const peers = entry.peerDependencies;
186
+ if (typeof peers !== 'object' || peers === null) continue;
187
+ const meta = (entry.peerDependenciesMeta ?? {}) as Record<
188
+ string,
189
+ { optional?: unknown } | undefined
190
+ >;
191
+ for (const key of ['react', 'react-dom']) {
192
+ const range = (peers as Record<string, unknown>)[key];
193
+ if (typeof range !== 'string') continue;
194
+ if (meta[key]?.optional === true) continue;
195
+ if (admitsMajor(range)) continue;
196
+ return { name: path.replace(/^.*node_modules\//, ''), key, range };
197
+ }
198
+ }
199
+ return undefined;
200
+ }
201
+
202
+ /**
203
+ * Whether a range admits any release of the family's React major. A range that
204
+ * cannot be parsed is given the benefit of the doubt: a checker that accuses on
205
+ * a spelling it does not know teaches people to ignore it.
206
+ * @param range - The peer range, as written.
207
+ * @returns True when some version of the major satisfies it.
208
+ */
209
+ function admitsMajor(range: string): boolean {
210
+ try {
211
+ return (
212
+ semver.intersects(range, `>=${REACT_MAJOR}.0.0 <${REACT_MAJOR + 1}.0.0`, {
213
+ includePrerelease: true,
214
+ }) ||
215
+ semver.satisfies(`${REACT_MAJOR}.0.0`, range, { includePrerelease: true })
216
+ );
217
+ } catch {
218
+ return true;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * What the root `package.json` asks for and what it pins, per tracked package.
224
+ * @param packageJson - The file, unparsed.
225
+ * @returns The declaration of each tracked package that has one.
226
+ */
227
+ function declarations(packageJson: string | undefined): Map<string, Declared> {
228
+ const found = new Map<string, Declared>();
229
+ const parsed = parseJsonObject(packageJson);
230
+ if (parsed === undefined) return found;
231
+ const dependencies = {
232
+ ...stringEntries(parsed.dependencies),
233
+ ...stringEntries(parsed.devDependencies),
234
+ };
235
+ const overrides = stringEntries(parsed.overrides);
236
+ for (const name of SINGLE_COPY) {
237
+ const dependency = dependencies[name];
238
+ const override = overrides[name];
239
+ if (dependency === undefined && override === undefined) continue;
240
+ found.set(name, { dependency, override });
241
+ }
242
+ return found;
243
+ }
@@ -16,7 +16,11 @@ export type DeployProblemKind =
16
16
  | 'ungated-build'
17
17
  | 'missing-build-only'
18
18
  | 'test-runs-e2e'
19
- | 'gated-image-build';
19
+ | 'gated-image-build'
20
+ | 'duplicate-react'
21
+ | 'stray-react-major'
22
+ | 'unpinned-react'
23
+ | 'conflicting-react-pin';
20
24
 
21
25
  /** One way a repository would fail, or silently mis-deploy, on the server. */
22
26
  export interface DeployProblem {
@@ -311,6 +311,19 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
311
311
  brandAlt: '#0e7490',
312
312
  mark: { plate: '#a3620a', accent: '#22d3ee' },
313
313
  },
314
+ {
315
+ id: 'powerpoint',
316
+ name: { lead: 'powerpoint', alt: 'cheminfo', dot: true },
317
+ host: 'powerpoint.cheminfo.org',
318
+ tagline:
319
+ 'Put a live tool on a slide, from the link its Share button gives.',
320
+ group: 'computing',
321
+ brand: '#c8460d',
322
+ // The blue sits on the white slide of the mark, where it reads at 6.7:1,
323
+ // and is dark enough to set the second half of the wordmark in as well.
324
+ brandAlt: '#1d4ed8',
325
+ mark: { plate: '#c8460d', accent: '#1d4ed8' },
326
+ },
314
327
  ];
315
328
 
316
329
  /** How a link to a site is written, beyond the site itself. */
@@ -28,7 +28,8 @@ export type SiteId =
28
28
  | 'osiris'
29
29
  | 'atoms'
30
30
  | 'moles'
31
- | 'inorganic';
31
+ | 'inorganic'
32
+ | 'powerpoint';
32
33
 
33
34
  /** A site's name, split the way its own wordmark splits it. */
34
35
  export interface SiteName {
@@ -470,4 +470,20 @@ export const GLYPHS: Record<SiteId, (alt: string) => ReactNode> = {
470
470
  <circle cx="22.5" cy="22.5" r="4.3" fill={alt} />
471
471
  </>
472
472
  ),
473
+ // A slide on its stand, with a ring standing on it: the slide is the
474
+ // presentation and the ring is the live tool put on it, so the ring carries
475
+ // the answering colour.
476
+ powerpoint: (alt) => (
477
+ <>
478
+ <rect x="4.5" y="6.5" width="23" height="14.5" rx="1.6" fill="#ffffff" />
479
+ <path
480
+ d="M16 21v5M11.5 26.5h9"
481
+ fill="none"
482
+ stroke="#ffffff"
483
+ strokeWidth="2.2"
484
+ strokeLinecap="round"
485
+ />
486
+ <path d="M16 8.45 20.59 11.1V16.4L16 19.05 11.41 16.4V11.1Z" fill={alt} />
487
+ </>
488
+ ),
473
489
  };
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "language.label": "Sprache",
3
3
  "chrome.pages": "Seiten",
4
+ "chrome.about": "Über",
5
+ "chrome.aboutTitle": "Was diese Seite ist, worauf sie aufbaut und wie man sie zitiert",
4
6
  "account.signIn": "Anmelden",
5
7
  "account.signOut": "Abmelden",
6
8
  "account.signedInAs": "Angemeldet als {name}",
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "language.label": "Language",
3
3
  "chrome.pages": "Pages",
4
+ "chrome.about": "About",
5
+ "chrome.aboutTitle": "What this site is, what it is built on, and how to cite it",
4
6
  "account.signIn": "Sign in",
5
7
  "account.signOut": "Sign out",
6
8
  "account.signedInAs": "Signed in as {name}",
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "language.label": "Idioma",
3
3
  "chrome.pages": "Páginas",
4
+ "chrome.about": "Acerca de",
5
+ "chrome.aboutTitle": "Qué es este sitio, en qué se basa y cómo citarlo",
4
6
  "account.signIn": "Iniciar sesión",
5
7
  "account.signOut": "Cerrar sesión",
6
8
  "account.signedInAs": "Sesión iniciada como {name}",