react-cheminfo 0.59.0 → 0.61.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 (140) hide show
  1. package/README.md +36 -2
  2. package/lib/about/ui/AboutBuildDetails.d.ts +15 -0
  3. package/lib/about/ui/AboutBuildDetails.d.ts.map +1 -0
  4. package/lib/about/ui/AboutBuildDetails.js +17 -0
  5. package/lib/about/ui/AboutBuildDetails.js.map +1 -0
  6. package/lib/about/ui/AboutPage.d.ts +8 -0
  7. package/lib/about/ui/AboutPage.d.ts.map +1 -1
  8. package/lib/about/ui/AboutPage.js +12 -3
  9. package/lib/about/ui/AboutPage.js.map +1 -1
  10. package/lib/about/ui/AboutSignIn.d.ts +33 -0
  11. package/lib/about/ui/AboutSignIn.d.ts.map +1 -0
  12. package/lib/about/ui/AboutSignIn.js +28 -0
  13. package/lib/about/ui/AboutSignIn.js.map +1 -0
  14. package/lib/about/ui/AboutVersion.d.ts +4 -2
  15. package/lib/about/ui/AboutVersion.d.ts.map +1 -1
  16. package/lib/about/ui/AboutVersion.js +21 -3
  17. package/lib/about/ui/AboutVersion.js.map +1 -1
  18. package/lib/about/ui/index.d.ts +2 -2
  19. package/lib/about/ui/index.d.ts.map +1 -1
  20. package/lib/about/ui/index.js +1 -1
  21. package/lib/about/ui/index.js.map +1 -1
  22. package/lib/chrome/ui/NavEntry.d.ts +16 -0
  23. package/lib/chrome/ui/NavEntry.d.ts.map +1 -0
  24. package/lib/chrome/ui/NavEntry.js +27 -0
  25. package/lib/chrome/ui/NavEntry.js.map +1 -0
  26. package/lib/chrome/ui/SiteHeader.d.ts +11 -4
  27. package/lib/chrome/ui/SiteHeader.d.ts.map +1 -1
  28. package/lib/chrome/ui/SiteHeader.js +4 -26
  29. package/lib/chrome/ui/SiteHeader.js.map +1 -1
  30. package/lib/core.d.ts +1 -0
  31. package/lib/core.d.ts.map +1 -1
  32. package/lib/core.js +1 -0
  33. package/lib/core.js.map +1 -1
  34. package/lib/i18n/core/chromeCatalog.d.ts +1 -1
  35. package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
  36. package/lib/locales/de.json +11 -3
  37. package/lib/locales/en.json +11 -3
  38. package/lib/locales/es.json +11 -3
  39. package/lib/locales/fr.json +11 -3
  40. package/lib/locales/it.json +11 -3
  41. package/lib/moleculesSources/core/index.d.ts +3 -0
  42. package/lib/moleculesSources/core/index.d.ts.map +1 -0
  43. package/lib/moleculesSources/core/index.js +2 -0
  44. package/lib/moleculesSources/core/index.js.map +1 -0
  45. package/lib/moleculesSources/core/moleculesSources.d.ts +55 -0
  46. package/lib/moleculesSources/core/moleculesSources.d.ts.map +1 -0
  47. package/lib/moleculesSources/core/moleculesSources.js +63 -0
  48. package/lib/moleculesSources/core/moleculesSources.js.map +1 -0
  49. package/lib/moleculesSources/ui/MoleculesSourceBadge.d.ts +18 -0
  50. package/lib/moleculesSources/ui/MoleculesSourceBadge.d.ts.map +1 -0
  51. package/lib/moleculesSources/ui/MoleculesSourceBadge.js +22 -0
  52. package/lib/moleculesSources/ui/MoleculesSourceBadge.js.map +1 -0
  53. package/lib/moleculesSources/ui/SourceBadge.d.ts +57 -0
  54. package/lib/moleculesSources/ui/SourceBadge.d.ts.map +1 -0
  55. package/lib/moleculesSources/ui/SourceBadge.js +29 -0
  56. package/lib/moleculesSources/ui/SourceBadge.js.map +1 -0
  57. package/lib/moleculesSources/ui/index.d.ts +7 -0
  58. package/lib/moleculesSources/ui/index.d.ts.map +1 -0
  59. package/lib/moleculesSources/ui/index.js +4 -0
  60. package/lib/moleculesSources/ui/index.js.map +1 -0
  61. package/lib/moleculesSources/ui/useSourceLooks.d.ts +24 -0
  62. package/lib/moleculesSources/ui/useSourceLooks.d.ts.map +1 -0
  63. package/lib/moleculesSources/ui/useSourceLooks.js +53 -0
  64. package/lib/moleculesSources/ui/useSourceLooks.js.map +1 -0
  65. package/lib/moleculesToken/core/index.d.ts +1 -0
  66. package/lib/moleculesToken/core/index.d.ts.map +1 -1
  67. package/lib/moleculesToken/core/index.js +1 -0
  68. package/lib/moleculesToken/core/index.js.map +1 -1
  69. package/lib/moleculesToken/core/tokenFix.d.ts +18 -0
  70. package/lib/moleculesToken/core/tokenFix.d.ts.map +1 -0
  71. package/lib/moleculesToken/core/tokenFix.js +27 -0
  72. package/lib/moleculesToken/core/tokenFix.js.map +1 -0
  73. package/lib/moleculesToken/ui/MoleculesTokenFix.d.ts +22 -0
  74. package/lib/moleculesToken/ui/MoleculesTokenFix.d.ts.map +1 -0
  75. package/lib/moleculesToken/ui/MoleculesTokenFix.js +35 -0
  76. package/lib/moleculesToken/ui/MoleculesTokenFix.js.map +1 -0
  77. package/lib/moleculesToken/ui/MoleculesTokenTable.d.ts +6 -1
  78. package/lib/moleculesToken/ui/MoleculesTokenTable.d.ts.map +1 -1
  79. package/lib/moleculesToken/ui/MoleculesTokenTable.js +7 -75
  80. package/lib/moleculesToken/ui/MoleculesTokenTable.js.map +1 -1
  81. package/lib/moleculesToken/ui/index.d.ts +2 -0
  82. package/lib/moleculesToken/ui/index.d.ts.map +1 -1
  83. package/lib/moleculesToken/ui/index.js +1 -0
  84. package/lib/moleculesToken/ui/index.js.map +1 -1
  85. package/lib/moleculesToken/ui/tokenDetails.d.ts +13 -0
  86. package/lib/moleculesToken/ui/tokenDetails.d.ts.map +1 -0
  87. package/lib/moleculesToken/ui/tokenDetails.js +73 -0
  88. package/lib/moleculesToken/ui/tokenDetails.js.map +1 -0
  89. package/lib/server/siteFiles.d.ts +28 -0
  90. package/lib/server/siteFiles.d.ts.map +1 -0
  91. package/lib/server/siteFiles.js +57 -0
  92. package/lib/server/siteFiles.js.map +1 -0
  93. package/lib/server/staticSite.d.ts +42 -0
  94. package/lib/server/staticSite.d.ts.map +1 -0
  95. package/lib/server/staticSite.js +114 -0
  96. package/lib/server/staticSite.js.map +1 -0
  97. package/lib/server.d.ts +4 -0
  98. package/lib/server.d.ts.map +1 -0
  99. package/lib/server.js +3 -0
  100. package/lib/server.js.map +1 -0
  101. package/lib/ui.d.ts +1 -0
  102. package/lib/ui.d.ts.map +1 -1
  103. package/lib/ui.js +1 -0
  104. package/lib/ui.js.map +1 -1
  105. package/package.json +12 -1
  106. package/src/about/ui/AboutBuildDetails.tsx +45 -0
  107. package/src/about/ui/AboutPage.tsx +27 -13
  108. package/src/about/ui/AboutSignIn.tsx +63 -0
  109. package/src/about/ui/AboutVersion.tsx +27 -9
  110. package/src/about/ui/index.ts +2 -2
  111. package/src/chrome/ui/NavEntry.tsx +41 -0
  112. package/src/chrome/ui/SiteHeader.tsx +15 -41
  113. package/src/core.ts +1 -0
  114. package/src/locales/de.json +11 -3
  115. package/src/locales/en.json +11 -3
  116. package/src/locales/es.json +11 -3
  117. package/src/locales/fr.json +11 -3
  118. package/src/locales/it.json +11 -3
  119. package/src/moleculesSources/core/index.ts +11 -0
  120. package/src/moleculesSources/core/moleculesSources.ts +102 -0
  121. package/src/moleculesSources/ui/MoleculesSourceBadge.tsx +44 -0
  122. package/src/moleculesSources/ui/SourceBadge.tsx +117 -0
  123. package/src/moleculesSources/ui/index.ts +6 -0
  124. package/src/moleculesSources/ui/useSourceLooks.ts +70 -0
  125. package/src/moleculesToken/core/index.ts +1 -0
  126. package/src/moleculesToken/core/tokenFix.ts +37 -0
  127. package/src/moleculesToken/ui/MoleculesTokenFix.tsx +95 -0
  128. package/src/moleculesToken/ui/MoleculesTokenTable.tsx +20 -94
  129. package/src/moleculesToken/ui/index.ts +2 -0
  130. package/src/moleculesToken/ui/tokenDetails.tsx +91 -0
  131. package/src/server/siteFiles.ts +64 -0
  132. package/src/server/staticSite.ts +188 -0
  133. package/src/server.ts +7 -0
  134. package/src/ui.ts +1 -0
  135. package/styles/chrome.css +159 -0
  136. package/lib/about/ui/AboutBuild.d.ts +0 -18
  137. package/lib/about/ui/AboutBuild.d.ts.map +0 -1
  138. package/lib/about/ui/AboutBuild.js +0 -37
  139. package/lib/about/ui/AboutBuild.js.map +0 -1
  140. package/src/about/ui/AboutBuild.tsx +0 -54
@@ -0,0 +1,95 @@
1
+ import { Callout } from '@blueprintjs/core';
2
+ import type { ReactElement } from 'react';
3
+
4
+ import { CodeBlock } from '../../clipboard/ui/CodeBlock.tsx';
5
+ import { useLanguage } from '../../i18n/ui/useLanguage.ts';
6
+ import { useChromeT } from '../../i18n/ui/useT.ts';
7
+ import { trimTrailingSlash } from '../../router/core/address.ts';
8
+ import type { MoleculesTokenCheck } from '../core/moleculesToken.ts';
9
+ import { issueTokenCommand, tokensToFix } from '../core/tokenFix.ts';
10
+
11
+ /** Where molecules.cheminfo.org answers when the site names no other. */
12
+ const DEFAULT_MOLECULES_URL = 'https://molecules.cheminfo.org';
13
+
14
+ /** What {@link MoleculesTokenFix} shows. */
15
+ export interface MoleculesTokenFixProps {
16
+ /** The last checks. */
17
+ checks: readonly MoleculesTokenCheck[];
18
+ /**
19
+ * Where molecules.cheminfo.org answers, whose Admin page issues tokens.
20
+ * @default 'https://molecules.cheminfo.org'
21
+ */
22
+ moleculesUrl?: string;
23
+ }
24
+
25
+ /**
26
+ * What to do about the tokens that have to be replaced — none set, refused, or
27
+ * issued for another source — in the three steps it takes: issue one on
28
+ * molecules.cheminfo.org, write it in the server's `.env`, restart. Nothing
29
+ * when every token is fine.
30
+ * @param props - See {@link MoleculesTokenFixProps}.
31
+ * @returns The steps, or null.
32
+ */
33
+ export function MoleculesTokenFix(
34
+ props: MoleculesTokenFixProps,
35
+ ): ReactElement | null {
36
+ const { checks, moleculesUrl = DEFAULT_MOLECULES_URL } = props;
37
+ const t = useChromeT();
38
+ const language = useLanguage();
39
+ const toFix = tokensToFix(checks);
40
+ if (toFix.length === 0) return null;
41
+
42
+ const one = toFix.length === 1;
43
+ const variables = new Intl.ListFormat(language, {
44
+ type: 'conjunction',
45
+ }).format(toFix.map((check) => check.variable));
46
+ const admin = `${trimTrailingSlash(moleculesUrl)}/admin`;
47
+ const unsetOnly = toFix.every((check) => check.verdict === 'unset');
48
+
49
+ return (
50
+ <Callout
51
+ intent={unsetOnly ? 'warning' : 'danger'}
52
+ icon="key"
53
+ title={t('moleculesToken.fix.title', { variables })}
54
+ className="molecules-tokens__fix"
55
+ data-testid="molecules-tokens-fix"
56
+ >
57
+ <ol>
58
+ <li>
59
+ <p>
60
+ {t(
61
+ one
62
+ ? 'moleculesToken.fix.issueOne'
63
+ : 'moleculesToken.fix.issueMany',
64
+ )}{' '}
65
+ <a href={admin} target="_blank" rel="noreferrer">
66
+ {admin}
67
+ </a>
68
+ </p>
69
+ <p>{t('moleculesToken.fix.issueOnServer')}</p>
70
+ <CodeBlock
71
+ tone="muted"
72
+ copyable
73
+ code={[...new Set(toFix.map(issueTokenCommand))].join('\n')}
74
+ />
75
+ </li>
76
+ <li>
77
+ <p>
78
+ {t(
79
+ one ? 'moleculesToken.fix.envOne' : 'moleculesToken.fix.envMany',
80
+ )}
81
+ </p>
82
+ <CodeBlock
83
+ tone="muted"
84
+ copyable
85
+ code={toFix.map((check) => `${check.variable}=mct_…`).join('\n')}
86
+ />
87
+ </li>
88
+ <li>
89
+ <p>{t('moleculesToken.fix.restart')}</p>
90
+ <CodeBlock tone="muted" copyable code="docker compose up -d" />
91
+ </li>
92
+ </ol>
93
+ </Callout>
94
+ );
95
+ }
@@ -1,16 +1,18 @@
1
1
  import type { Intent } from '@blueprintjs/core';
2
2
  import { Button, Callout, HTMLTable, Tag } from '@blueprintjs/core';
3
- import type { ReactNode } from 'react';
4
3
  import { useMemo } from 'react';
5
4
 
6
5
  import type { ChromeKey } from '../../i18n/core/chromeCatalog.ts';
7
6
  import { useLanguage } from '../../i18n/ui/useLanguage.ts';
8
- import type { Translate } from '../../i18n/ui/useT.ts';
9
7
  import { useChromeT } from '../../i18n/ui/useT.ts';
10
8
  import type {
11
9
  MoleculesTokenCheck,
12
10
  MoleculesTokenVerdict,
13
11
  } from '../core/moleculesToken.ts';
12
+ import { tokensToFix } from '../core/tokenFix.ts';
13
+
14
+ import { MoleculesTokenFix } from './MoleculesTokenFix.tsx';
15
+ import { tokenDetails } from './tokenDetails.tsx';
14
16
 
15
17
  /** What {@link MoleculesTokenTable} shows. */
16
18
  export interface MoleculesTokenTableProps {
@@ -32,6 +34,11 @@ export interface MoleculesTokenTableProps {
32
34
  * @default undefined
33
35
  */
34
36
  error?: string;
37
+ /**
38
+ * Where molecules.cheminfo.org answers, whose Admin page issues tokens.
39
+ * @default 'https://molecules.cheminfo.org'
40
+ */
41
+ moleculesUrl?: string;
35
42
  }
36
43
 
37
44
  const INTENT: Record<MoleculesTokenVerdict, Intent> = {
@@ -60,12 +67,12 @@ const ISSUE_COMMAND = 'npm run tokens -- issue SOURCE EMAIL';
60
67
  * The tokens a site holds for molecules.cheminfo.org, one row each, with what
61
68
  * molecules.cheminfo.org said of it when it was last checked: valid, another
62
69
  * source's, refused, not set. A token is changed in the server's `.env`, never
63
- * here, so the table says where.
70
+ * here, so the table says where, and above it how, when one has to be.
64
71
  * @param props - See {@link MoleculesTokenTableProps}.
65
72
  * @returns The table.
66
73
  */
67
74
  export function MoleculesTokenTable(props: MoleculesTokenTableProps) {
68
- const { checks, pending = false, onCheckAgain, error } = props;
75
+ const { checks, pending = false, onCheckAgain, error, moleculesUrl } = props;
69
76
  const t = useChromeT();
70
77
  const language = useLanguage();
71
78
  const formats = useMemo(
@@ -117,6 +124,9 @@ export function MoleculesTokenTable(props: MoleculesTokenTableProps) {
117
124
  {error}
118
125
  </Callout>
119
126
  )}
127
+ {checks !== null && (
128
+ <MoleculesTokenFix checks={checks} moleculesUrl={moleculesUrl} />
129
+ )}
120
130
  {checks === null ? (
121
131
  <p className="molecules-tokens__muted">
122
132
  {t('moleculesToken.notChecked')}
@@ -148,102 +158,18 @@ export function MoleculesTokenTable(props: MoleculesTokenTableProps) {
148
158
  {t(LABEL[check.verdict])}
149
159
  </Tag>
150
160
  </td>
151
- <td>{details(check, t, formats.instant)}</td>
161
+ <td>{tokenDetails(check, t, formats.instant)}</td>
152
162
  </tr>
153
163
  ))}
154
164
  </tbody>
155
165
  </HTMLTable>
156
166
  </div>
157
167
  )}
158
- <p className="molecules-tokens__muted">
159
- {t('moleculesToken.howToChange')} <code>{ISSUE_COMMAND}</code>
160
- </p>
168
+ {(checks === null || tokensToFix(checks).length === 0) && (
169
+ <p className="molecules-tokens__muted">
170
+ {t('moleculesToken.howToChange')} <code>{ISSUE_COMMAND}</code>
171
+ </p>
172
+ )}
161
173
  </section>
162
174
  );
163
175
  }
164
-
165
- /**
166
- * What a row says besides its verdict.
167
- * @param check - The check.
168
- * @param t - The chrome's translator.
169
- * @param instant - How an instant is written.
170
- * @returns The cell's content.
171
- */
172
- function details(
173
- check: MoleculesTokenCheck,
174
- t: Translate<ChromeKey>,
175
- instant: Intl.DateTimeFormat,
176
- ): ReactNode {
177
- const hint: Fact | null =
178
- check.hint === null
179
- ? null
180
- : ['hint', <code key="hint">{check.hint}…</code>];
181
- switch (check.verdict) {
182
- case 'ok':
183
- return facts([
184
- check.sources.length === 1 || check.source === null
185
- ? null
186
- : ['source', t('moleculesToken.forSource', { source: check.source })],
187
- check.issuedAt === null
188
- ? null
189
- : [
190
- 'issued',
191
- check.email === null
192
- ? t('moleculesToken.issued', {
193
- when: instant.format(check.issuedAt),
194
- })
195
- : t('moleculesToken.issuedTo', {
196
- email: check.email,
197
- when: instant.format(check.issuedAt),
198
- }),
199
- ],
200
- [
201
- 'used',
202
- check.lastUsedAt === null
203
- ? t('moleculesToken.neverUsed')
204
- : t('moleculesToken.lastUsed', {
205
- when: instant.format(check.lastUsedAt),
206
- }),
207
- ],
208
- hint,
209
- ]);
210
- case 'otherSource':
211
- return facts([
212
- [
213
- 'source',
214
- t('moleculesToken.otherSourceDetail', {
215
- source: check.source ?? '',
216
- }),
217
- ],
218
- hint,
219
- ]);
220
- case 'refused':
221
- return check.message ?? t('moleculesToken.refusedDetail');
222
- case 'unset':
223
- return t('moleculesToken.unsetDetail', { variable: check.variable });
224
- case 'uncheckable':
225
- return t('moleculesToken.uncheckableDetail');
226
- case 'unanswered':
227
- return check.message;
228
- default:
229
- return null;
230
- }
231
- }
232
-
233
- /** One fact about a token, and the key it is drawn under. */
234
- type Fact = readonly [key: string, content: ReactNode];
235
-
236
- /**
237
- * Facts about a token, one after the other.
238
- * @param parts - The facts; null ones are left out.
239
- * @returns The facts, separated by a middle dot.
240
- */
241
- function facts(parts: ReadonlyArray<Fact | null>): ReactNode {
242
- const shown = parts.filter((part) => part !== null);
243
- return shown.map(([key, content], index) => (
244
- <span key={key}>
245
- {index > 0 && ' · '}
246
- {content}
247
- </span>
248
- ));
249
- }
@@ -1,2 +1,4 @@
1
+ export type { MoleculesTokenFixProps } from './MoleculesTokenFix.tsx';
2
+ export { MoleculesTokenFix } from './MoleculesTokenFix.tsx';
1
3
  export type { MoleculesTokenTableProps } from './MoleculesTokenTable.tsx';
2
4
  export { MoleculesTokenTable } from './MoleculesTokenTable.tsx';
@@ -0,0 +1,91 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import type { ChromeKey } from '../../i18n/core/chromeCatalog.ts';
4
+ import type { Translate } from '../../i18n/ui/useT.ts';
5
+ import type { MoleculesTokenCheck } from '../core/moleculesToken.ts';
6
+
7
+ /**
8
+ * What a row says besides its verdict.
9
+ * @param check - The check.
10
+ * @param t - The chrome's translator.
11
+ * @param instant - How an instant is written.
12
+ * @returns The cell's content.
13
+ */
14
+ export function tokenDetails(
15
+ check: MoleculesTokenCheck,
16
+ t: Translate<ChromeKey>,
17
+ instant: Intl.DateTimeFormat,
18
+ ): ReactNode {
19
+ const hint: Fact | null =
20
+ check.hint === null
21
+ ? null
22
+ : ['hint', <code key="hint">{check.hint}…</code>];
23
+ switch (check.verdict) {
24
+ case 'ok':
25
+ return facts([
26
+ check.sources.length === 1 || check.source === null
27
+ ? null
28
+ : ['source', t('moleculesToken.forSource', { source: check.source })],
29
+ check.issuedAt === null
30
+ ? null
31
+ : [
32
+ 'issued',
33
+ check.email === null
34
+ ? t('moleculesToken.issued', {
35
+ when: instant.format(check.issuedAt),
36
+ })
37
+ : t('moleculesToken.issuedTo', {
38
+ email: check.email,
39
+ when: instant.format(check.issuedAt),
40
+ }),
41
+ ],
42
+ [
43
+ 'used',
44
+ check.lastUsedAt === null
45
+ ? t('moleculesToken.neverUsed')
46
+ : t('moleculesToken.lastUsed', {
47
+ when: instant.format(check.lastUsedAt),
48
+ }),
49
+ ],
50
+ hint,
51
+ ]);
52
+ case 'otherSource':
53
+ return facts([
54
+ [
55
+ 'source',
56
+ t('moleculesToken.otherSourceDetail', {
57
+ source: check.source ?? '',
58
+ }),
59
+ ],
60
+ hint,
61
+ ]);
62
+ case 'refused':
63
+ return check.message ?? t('moleculesToken.refusedDetail');
64
+ case 'unset':
65
+ return t('moleculesToken.unsetDetail', { variable: check.variable });
66
+ case 'uncheckable':
67
+ return t('moleculesToken.uncheckableDetail');
68
+ case 'unanswered':
69
+ return check.message;
70
+ default:
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /** One fact about a token, and the key it is drawn under. */
76
+ type Fact = readonly [key: string, content: ReactNode];
77
+
78
+ /**
79
+ * Facts about a token, one after the other.
80
+ * @param parts - The facts; null ones are left out.
81
+ * @returns The facts, separated by a middle dot.
82
+ */
83
+ function facts(parts: ReadonlyArray<Fact | null>): ReactNode {
84
+ const shown = parts.filter((part) => part !== null);
85
+ return shown.map(([key, content], index) => (
86
+ <span key={key}>
87
+ {index > 0 && ' · '}
88
+ {content}
89
+ </span>
90
+ ));
91
+ }
@@ -0,0 +1,64 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { join, relative, sep } from 'node:path';
4
+
5
+ /**
6
+ * Every file of a built site, by its path from the root, written with `/`.
7
+ *
8
+ * The build does not change while the server runs, so the list is read once,
9
+ * and only a file on it is ever served: a path that climbs out of the root
10
+ * names nothing on the list.
11
+ * @param root - The directory the build wrote, e.g. `dist`.
12
+ * @returns The paths, e.g. `titration.html` and `assets/index-a1b2.js`, or
13
+ * `undefined` when the directory does not exist.
14
+ */
15
+ export async function listSiteFiles(
16
+ root: string,
17
+ ): Promise<Set<string> | undefined> {
18
+ if (!existsSync(root)) return undefined;
19
+ const entries = await readdir(root, { recursive: true, withFileTypes: true });
20
+ const files = new Set<string>();
21
+ for (const entry of entries) {
22
+ if (!entry.isFile()) continue;
23
+ files.add(
24
+ relative(root, join(entry.parentPath, entry.name)).split(sep).join('/'),
25
+ );
26
+ }
27
+ return files;
28
+ }
29
+
30
+ /**
31
+ * The file that answers an address, as the static hosts of the family answer
32
+ * it: `/titration` is `titration.html` (never a redirect to `/titration/`),
33
+ * `/` and `/fr/` are their `index.html`, and any other file is itself.
34
+ * @param files - The files of the site, from {@link listSiteFiles}.
35
+ * @param pathname - The address, without its query, e.g. `/titration`.
36
+ * @returns The file, or `undefined` when the site has no such page.
37
+ */
38
+ export function resolveAddress(
39
+ files: ReadonlySet<string>,
40
+ pathname: string,
41
+ ): string | undefined {
42
+ let path: string;
43
+ try {
44
+ path = decodeURIComponent(pathname);
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ const local = path.replace(/^\/+/, '');
49
+ const candidates =
50
+ local === '' || local.endsWith('/')
51
+ ? [`${local}index.html`]
52
+ : [local, `${local}.html`, `${local}/index.html`];
53
+ return candidates.find((candidate) => files.has(candidate));
54
+ }
55
+
56
+ /**
57
+ * Whether a file is a bundle named by its content, which a browser may keep
58
+ * for a year: everything under an `assets` directory.
59
+ * @param file - The file, from the root.
60
+ * @returns True for a hashed bundle.
61
+ */
62
+ export function isHashedBundle(file: string): boolean {
63
+ return file.startsWith('assets/') || file.includes('/assets/');
64
+ }
@@ -0,0 +1,188 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join, relative, resolve, sep } from 'node:path';
3
+
4
+ import type {
5
+ FastifyInstance,
6
+ FastifyReply,
7
+ FastifyRequest,
8
+ FastifySchema,
9
+ } from 'fastify';
10
+
11
+ import { injectTrackingScript } from '../seo/core/trackingScript.ts';
12
+
13
+ import { isHashedBundle, listSiteFiles, resolveAddress } from './siteFiles.ts';
14
+
15
+ /** Options of {@link staticSite}. */
16
+ export interface StaticSiteOptions {
17
+ /** The directory the build wrote, e.g. `dist`. */
18
+ root: string;
19
+ /**
20
+ * The analytics snippet, put at the end of the head of every page, as
21
+ * `TRACKING_SCRIPT` holds it.
22
+ * @default undefined - no snippet
23
+ */
24
+ trackingScript?: string;
25
+ /**
26
+ * Addresses the site does not have any more, and the one that does their
27
+ * work. The query is carried over, so a link handed out before the move
28
+ * keeps its configuration.
29
+ * @default {}
30
+ */
31
+ redirects?: Readonly<Record<string, string>>;
32
+ /**
33
+ * Address prefixes another plugin answers, such as the API, which never
34
+ * answer a page: an unknown address there is the API's own 404.
35
+ * @default ['/v1/']
36
+ */
37
+ reserved?: readonly string[];
38
+ }
39
+
40
+ /** A page: the file with the snippet in its head, and the tag a browser checks. */
41
+ interface Page {
42
+ html: string;
43
+ etag: string;
44
+ }
45
+
46
+ /** A page is checked on every visit: it names the bundles of its own build. */
47
+ const PAGE_CACHE = 'no-cache';
48
+
49
+ /** A bundle is named by its content, so it never changes under its name. */
50
+ const BUNDLE_CACHE = 'public, max-age=31536000, immutable';
51
+
52
+ /** Kept out of the API's documentation (`@fastify/swagger` reads `hide`). */
53
+ const HIDDEN: FastifySchema & { hide: boolean } = { hide: true };
54
+
55
+ /**
56
+ * Serve a site's build from Node, with the rules every static host of the
57
+ * family follows (static-web-server's `sws.toml`, Cloudflare Pages): a real
58
+ * file per address and no fallback page, `/titration` answered without a
59
+ * redirect, pages checked on every visit and hashed bundles kept for a year,
60
+ * the analytics snippet in every page, `404.html` for an address the site does
61
+ * not have, and `/health` for the deploy script.
62
+ *
63
+ * Register it next to the API; a build missing (a server started before the
64
+ * frontend was built) leaves the API alone:
65
+ *
66
+ * await app.register(staticSite, { root: 'dist', trackingScript });
67
+ * @param fastify - The server.
68
+ * @param options - The build, the snippet and the retired addresses.
69
+ */
70
+ export async function staticSite(
71
+ fastify: FastifyInstance,
72
+ options: StaticSiteOptions,
73
+ ): Promise<void> {
74
+ const { trackingScript, redirects = {}, reserved = ['/v1/'] } = options;
75
+ const root = resolve(options.root);
76
+
77
+ fastify.get(
78
+ '/health',
79
+ { logLevel: 'silent', schema: HIDDEN },
80
+ (_request, reply) => reply.send({ status: 'ok' }),
81
+ );
82
+
83
+ const files = await listSiteFiles(root);
84
+ if (files === undefined) {
85
+ fastify.log.warn({ root }, 'no built site: serving the API alone');
86
+ return;
87
+ }
88
+ const pages = await readPages(root, files, trackingScript);
89
+
90
+ // An optional peer, installed only by a site that has a server: importing
91
+ // react-cheminfo/server must not need it.
92
+ const { default: fastifyStatic } = await import('@fastify/static');
93
+ await fastify.register(fastifyStatic, {
94
+ root,
95
+ serve: false,
96
+ cacheControl: false,
97
+ setHeaders: (reply, path) => {
98
+ const file = relative(root, path).split(sep).join('/');
99
+ if (isHashedBundle(file)) {
100
+ reply.header('cache-control', BUNDLE_CACHE);
101
+ // A page framed by a sandboxed view may run under an opaque origin,
102
+ // and a module bundle is then fetched cross-origin.
103
+ reply.header('access-control-allow-origin', '*');
104
+ } else {
105
+ reply.header('cache-control', PAGE_CACHE);
106
+ }
107
+ },
108
+ });
109
+
110
+ const answer = (request: FastifyRequest, reply: FastifyReply) => {
111
+ const url = new URL(request.url, 'http://site');
112
+ const { pathname, search } = url;
113
+ if (reserved.some((prefix) => pathname.startsWith(prefix))) {
114
+ return reply.callNotFound();
115
+ }
116
+ const destination =
117
+ redirects[pathname] ?? redirects[pathname.replace(/\/$/, '')];
118
+ if (destination !== undefined) {
119
+ return reply.redirect(`${destination}${search}`, 301);
120
+ }
121
+
122
+ const file = resolveAddress(files, pathname);
123
+ if (file === undefined) {
124
+ return sendPage(request, reply, pages.get('404.html'), 404);
125
+ }
126
+ const page = pages.get(file);
127
+ if (page) return sendPage(request, reply, page, 200);
128
+ return reply.sendFile(file);
129
+ };
130
+
131
+ fastify.get('/', { schema: HIDDEN }, answer);
132
+ fastify.get('/*', { schema: HIDDEN }, answer);
133
+ }
134
+
135
+ /**
136
+ * Read every page of the build once, with the snippet in its head.
137
+ * @param root - The build.
138
+ * @param files - Its files.
139
+ * @param trackingScript - The snippet, if any.
140
+ * @returns The pages, by file.
141
+ */
142
+ async function readPages(
143
+ root: string,
144
+ files: ReadonlySet<string>,
145
+ trackingScript: string | undefined,
146
+ ): Promise<Map<string, Page>> {
147
+ const htmlFiles = [...files].filter((file) => file.endsWith('.html'));
148
+ const pages = await Promise.all(
149
+ htmlFiles.map(async (file) => {
150
+ const html = injectTrackingScript(
151
+ await readFile(join(root, file), 'utf8'),
152
+ trackingScript,
153
+ );
154
+ return [file, { html, etag: await etagOf(html) }] as const;
155
+ }),
156
+ );
157
+ return new Map(pages);
158
+ }
159
+
160
+ function sendPage(
161
+ request: FastifyRequest,
162
+ reply: FastifyReply,
163
+ page: Page | undefined,
164
+ status: number,
165
+ ) {
166
+ if (page === undefined) {
167
+ return reply
168
+ .code(status)
169
+ .type('text/plain; charset=utf-8')
170
+ .send('Not found');
171
+ }
172
+ reply.header('cache-control', PAGE_CACHE).header('etag', page.etag);
173
+ if (status === 200 && request.headers['if-none-match'] === page.etag) {
174
+ return reply.code(304).send();
175
+ }
176
+ return reply.code(status).type('text/html; charset=utf-8').send(page.html);
177
+ }
178
+
179
+ async function etagOf(text: string): Promise<string> {
180
+ const digest = await crypto.subtle.digest(
181
+ 'SHA-256',
182
+ new TextEncoder().encode(text),
183
+ );
184
+ const bytes = new Uint8Array(digest).subarray(0, 12);
185
+ let hex = '';
186
+ for (const byte of bytes) hex += byte.toString(16).padStart(2, '0');
187
+ return `"${hex}"`;
188
+ }
package/src/server.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type { StaticSiteOptions } from './server/staticSite.ts';
2
+ export { staticSite } from './server/staticSite.ts';
3
+ export {
4
+ isHashedBundle,
5
+ listSiteFiles,
6
+ resolveAddress,
7
+ } from './server/siteFiles.ts';
package/src/ui.ts CHANGED
@@ -18,6 +18,7 @@ export * from './help/ui/index.ts';
18
18
  export * from './hooks/ui/index.ts';
19
19
  export * from './i18n/ui/index.ts';
20
20
  export * from './language/ui/index.ts';
21
+ export * from './moleculesSources/ui/index.ts';
21
22
  export * from './moleculesToken/ui/index.ts';
22
23
  export * from './number/ui/index.ts';
23
24
  export * from './overlay/ui/index.ts';