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.
- package/README.md +36 -2
- package/lib/about/ui/AboutBuildDetails.d.ts +15 -0
- package/lib/about/ui/AboutBuildDetails.d.ts.map +1 -0
- package/lib/about/ui/AboutBuildDetails.js +17 -0
- package/lib/about/ui/AboutBuildDetails.js.map +1 -0
- package/lib/about/ui/AboutPage.d.ts +8 -0
- package/lib/about/ui/AboutPage.d.ts.map +1 -1
- package/lib/about/ui/AboutPage.js +12 -3
- package/lib/about/ui/AboutPage.js.map +1 -1
- package/lib/about/ui/AboutSignIn.d.ts +33 -0
- package/lib/about/ui/AboutSignIn.d.ts.map +1 -0
- package/lib/about/ui/AboutSignIn.js +28 -0
- package/lib/about/ui/AboutSignIn.js.map +1 -0
- package/lib/about/ui/AboutVersion.d.ts +4 -2
- package/lib/about/ui/AboutVersion.d.ts.map +1 -1
- package/lib/about/ui/AboutVersion.js +21 -3
- package/lib/about/ui/AboutVersion.js.map +1 -1
- package/lib/about/ui/index.d.ts +2 -2
- package/lib/about/ui/index.d.ts.map +1 -1
- package/lib/about/ui/index.js +1 -1
- package/lib/about/ui/index.js.map +1 -1
- package/lib/chrome/ui/NavEntry.d.ts +16 -0
- package/lib/chrome/ui/NavEntry.d.ts.map +1 -0
- package/lib/chrome/ui/NavEntry.js +27 -0
- package/lib/chrome/ui/NavEntry.js.map +1 -0
- package/lib/chrome/ui/SiteHeader.d.ts +11 -4
- package/lib/chrome/ui/SiteHeader.d.ts.map +1 -1
- package/lib/chrome/ui/SiteHeader.js +4 -26
- package/lib/chrome/ui/SiteHeader.js.map +1 -1
- package/lib/core.d.ts +1 -0
- package/lib/core.d.ts.map +1 -1
- package/lib/core.js +1 -0
- package/lib/core.js.map +1 -1
- package/lib/i18n/core/chromeCatalog.d.ts +1 -1
- package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
- package/lib/locales/de.json +11 -3
- package/lib/locales/en.json +11 -3
- package/lib/locales/es.json +11 -3
- package/lib/locales/fr.json +11 -3
- package/lib/locales/it.json +11 -3
- package/lib/moleculesSources/core/index.d.ts +3 -0
- package/lib/moleculesSources/core/index.d.ts.map +1 -0
- package/lib/moleculesSources/core/index.js +2 -0
- package/lib/moleculesSources/core/index.js.map +1 -0
- package/lib/moleculesSources/core/moleculesSources.d.ts +55 -0
- package/lib/moleculesSources/core/moleculesSources.d.ts.map +1 -0
- package/lib/moleculesSources/core/moleculesSources.js +63 -0
- package/lib/moleculesSources/core/moleculesSources.js.map +1 -0
- package/lib/moleculesSources/ui/MoleculesSourceBadge.d.ts +18 -0
- package/lib/moleculesSources/ui/MoleculesSourceBadge.d.ts.map +1 -0
- package/lib/moleculesSources/ui/MoleculesSourceBadge.js +22 -0
- package/lib/moleculesSources/ui/MoleculesSourceBadge.js.map +1 -0
- package/lib/moleculesSources/ui/SourceBadge.d.ts +57 -0
- package/lib/moleculesSources/ui/SourceBadge.d.ts.map +1 -0
- package/lib/moleculesSources/ui/SourceBadge.js +29 -0
- package/lib/moleculesSources/ui/SourceBadge.js.map +1 -0
- package/lib/moleculesSources/ui/index.d.ts +7 -0
- package/lib/moleculesSources/ui/index.d.ts.map +1 -0
- package/lib/moleculesSources/ui/index.js +4 -0
- package/lib/moleculesSources/ui/index.js.map +1 -0
- package/lib/moleculesSources/ui/useSourceLooks.d.ts +24 -0
- package/lib/moleculesSources/ui/useSourceLooks.d.ts.map +1 -0
- package/lib/moleculesSources/ui/useSourceLooks.js +53 -0
- package/lib/moleculesSources/ui/useSourceLooks.js.map +1 -0
- package/lib/moleculesToken/core/index.d.ts +1 -0
- package/lib/moleculesToken/core/index.d.ts.map +1 -1
- package/lib/moleculesToken/core/index.js +1 -0
- package/lib/moleculesToken/core/index.js.map +1 -1
- package/lib/moleculesToken/core/tokenFix.d.ts +18 -0
- package/lib/moleculesToken/core/tokenFix.d.ts.map +1 -0
- package/lib/moleculesToken/core/tokenFix.js +27 -0
- package/lib/moleculesToken/core/tokenFix.js.map +1 -0
- package/lib/moleculesToken/ui/MoleculesTokenFix.d.ts +22 -0
- package/lib/moleculesToken/ui/MoleculesTokenFix.d.ts.map +1 -0
- package/lib/moleculesToken/ui/MoleculesTokenFix.js +35 -0
- package/lib/moleculesToken/ui/MoleculesTokenFix.js.map +1 -0
- package/lib/moleculesToken/ui/MoleculesTokenTable.d.ts +6 -1
- package/lib/moleculesToken/ui/MoleculesTokenTable.d.ts.map +1 -1
- package/lib/moleculesToken/ui/MoleculesTokenTable.js +7 -75
- package/lib/moleculesToken/ui/MoleculesTokenTable.js.map +1 -1
- package/lib/moleculesToken/ui/index.d.ts +2 -0
- package/lib/moleculesToken/ui/index.d.ts.map +1 -1
- package/lib/moleculesToken/ui/index.js +1 -0
- package/lib/moleculesToken/ui/index.js.map +1 -1
- package/lib/moleculesToken/ui/tokenDetails.d.ts +13 -0
- package/lib/moleculesToken/ui/tokenDetails.d.ts.map +1 -0
- package/lib/moleculesToken/ui/tokenDetails.js +73 -0
- package/lib/moleculesToken/ui/tokenDetails.js.map +1 -0
- package/lib/server/siteFiles.d.ts +28 -0
- package/lib/server/siteFiles.d.ts.map +1 -0
- package/lib/server/siteFiles.js +57 -0
- package/lib/server/siteFiles.js.map +1 -0
- package/lib/server/staticSite.d.ts +42 -0
- package/lib/server/staticSite.d.ts.map +1 -0
- package/lib/server/staticSite.js +114 -0
- package/lib/server/staticSite.js.map +1 -0
- package/lib/server.d.ts +4 -0
- package/lib/server.d.ts.map +1 -0
- package/lib/server.js +3 -0
- package/lib/server.js.map +1 -0
- package/lib/ui.d.ts +1 -0
- package/lib/ui.d.ts.map +1 -1
- package/lib/ui.js +1 -0
- package/lib/ui.js.map +1 -1
- package/package.json +12 -1
- package/src/about/ui/AboutBuildDetails.tsx +45 -0
- package/src/about/ui/AboutPage.tsx +27 -13
- package/src/about/ui/AboutSignIn.tsx +63 -0
- package/src/about/ui/AboutVersion.tsx +27 -9
- package/src/about/ui/index.ts +2 -2
- package/src/chrome/ui/NavEntry.tsx +41 -0
- package/src/chrome/ui/SiteHeader.tsx +15 -41
- package/src/core.ts +1 -0
- package/src/locales/de.json +11 -3
- package/src/locales/en.json +11 -3
- package/src/locales/es.json +11 -3
- package/src/locales/fr.json +11 -3
- package/src/locales/it.json +11 -3
- package/src/moleculesSources/core/index.ts +11 -0
- package/src/moleculesSources/core/moleculesSources.ts +102 -0
- package/src/moleculesSources/ui/MoleculesSourceBadge.tsx +44 -0
- package/src/moleculesSources/ui/SourceBadge.tsx +117 -0
- package/src/moleculesSources/ui/index.ts +6 -0
- package/src/moleculesSources/ui/useSourceLooks.ts +70 -0
- package/src/moleculesToken/core/index.ts +1 -0
- package/src/moleculesToken/core/tokenFix.ts +37 -0
- package/src/moleculesToken/ui/MoleculesTokenFix.tsx +95 -0
- package/src/moleculesToken/ui/MoleculesTokenTable.tsx +20 -94
- package/src/moleculesToken/ui/index.ts +2 -0
- package/src/moleculesToken/ui/tokenDetails.tsx +91 -0
- package/src/server/siteFiles.ts +64 -0
- package/src/server/staticSite.ts +188 -0
- package/src/server.ts +7 -0
- package/src/ui.ts +1 -0
- package/styles/chrome.css +159 -0
- package/lib/about/ui/AboutBuild.d.ts +0 -18
- package/lib/about/ui/AboutBuild.d.ts.map +0 -1
- package/lib/about/ui/AboutBuild.js +0 -37
- package/lib/about/ui/AboutBuild.js.map +0 -1
- 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>{
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
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';
|