react-cheminfo 0.51.0 → 0.53.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 (137) hide show
  1. package/README.md +135 -46
  2. package/lib/host/core/connectHost.d.ts +75 -0
  3. package/lib/host/core/connectHost.d.ts.map +1 -0
  4. package/lib/host/core/connectHost.js +171 -0
  5. package/lib/host/core/connectHost.js.map +1 -0
  6. package/lib/host/core/hostStructure.d.ts +52 -0
  7. package/lib/host/core/hostStructure.d.ts.map +1 -0
  8. package/lib/host/core/hostStructure.js +106 -0
  9. package/lib/host/core/hostStructure.js.map +1 -0
  10. package/lib/host/core/index.d.ts +9 -0
  11. package/lib/host/core/index.d.ts.map +1 -0
  12. package/lib/host/core/index.js +5 -0
  13. package/lib/host/core/index.js.map +1 -0
  14. package/lib/host/core/kinds.d.ts +73 -0
  15. package/lib/host/core/kinds.d.ts.map +1 -0
  16. package/lib/host/core/kinds.js +88 -0
  17. package/lib/host/core/kinds.js.map +1 -0
  18. package/lib/host/core/protocol.d.ts +134 -0
  19. package/lib/host/core/protocol.d.ts.map +1 -0
  20. package/lib/host/core/protocol.js +92 -0
  21. package/lib/host/core/protocol.js.map +1 -0
  22. package/lib/host/testing/sciPeaks.d.ts +21 -0
  23. package/lib/host/testing/sciPeaks.d.ts.map +1 -0
  24. package/lib/host/testing/sciPeaks.js +62 -0
  25. package/lib/host/testing/sciPeaks.js.map +1 -0
  26. package/lib/host/testing/standInHost.d.ts +47 -0
  27. package/lib/host/testing/standInHost.d.ts.map +1 -0
  28. package/lib/host/testing/standInHost.js +85 -0
  29. package/lib/host/testing/standInHost.js.map +1 -0
  30. package/lib/host/ui/index.d.ts +3 -0
  31. package/lib/host/ui/index.d.ts.map +1 -0
  32. package/lib/host/ui/index.js +2 -0
  33. package/lib/host/ui/index.js.map +1 -0
  34. package/lib/host/ui/useHostConnection.d.ts +36 -0
  35. package/lib/host/ui/useHostConnection.d.ts.map +1 -0
  36. package/lib/host/ui/useHostConnection.js +62 -0
  37. package/lib/host/ui/useHostConnection.js.map +1 -0
  38. package/lib/host-testing.d.ts +8 -0
  39. package/lib/host-testing.d.ts.map +1 -0
  40. package/lib/host-testing.js +7 -0
  41. package/lib/host-testing.js.map +1 -0
  42. package/lib/host.d.ts +11 -0
  43. package/lib/host.d.ts.map +1 -0
  44. package/lib/host.js +11 -0
  45. package/lib/host.js.map +1 -0
  46. package/lib/i18n/core/chromeCatalog.d.ts +1 -1
  47. package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
  48. package/lib/language/ui/siteLanguageContext.d.ts.map +1 -1
  49. package/lib/language/ui/siteLanguageContext.js +2 -1
  50. package/lib/language/ui/siteLanguageContext.js.map +1 -1
  51. package/lib/locales/de.json +5 -0
  52. package/lib/locales/en.json +5 -0
  53. package/lib/locales/es.json +5 -0
  54. package/lib/locales/fr.json +5 -0
  55. package/lib/locales/it.json +5 -0
  56. package/lib/router/core/history.d.ts +4 -3
  57. package/lib/router/core/history.d.ts.map +1 -1
  58. package/lib/router/core/history.js +9 -13
  59. package/lib/router/core/history.js.map +1 -1
  60. package/lib/router/core/index.d.ts +2 -0
  61. package/lib/router/core/index.d.ts.map +1 -1
  62. package/lib/router/core/index.js +1 -0
  63. package/lib/router/core/index.js.map +1 -1
  64. package/lib/router/core/pageAddress.d.ts +61 -0
  65. package/lib/router/core/pageAddress.d.ts.map +1 -0
  66. package/lib/router/core/pageAddress.js +69 -0
  67. package/lib/router/core/pageAddress.js.map +1 -0
  68. package/lib/share/core/index.d.ts +4 -0
  69. package/lib/share/core/index.d.ts.map +1 -1
  70. package/lib/share/core/index.js +2 -0
  71. package/lib/share/core/index.js.map +1 -1
  72. package/lib/share/core/preferences.d.ts +2 -2
  73. package/lib/share/core/preferences.d.ts.map +1 -1
  74. package/lib/share/core/preferences.js +8 -3
  75. package/lib/share/core/preferences.js.map +1 -1
  76. package/lib/share/core/seal.d.ts +42 -0
  77. package/lib/share/core/seal.d.ts.map +1 -0
  78. package/lib/share/core/seal.js +185 -0
  79. package/lib/share/core/seal.js.map +1 -0
  80. package/lib/share/core/sealedAddress.d.ts +31 -0
  81. package/lib/share/core/sealedAddress.d.ts.map +1 -0
  82. package/lib/share/core/sealedAddress.js +38 -0
  83. package/lib/share/core/sealedAddress.js.map +1 -0
  84. package/lib/share/ui/SealedLinkRefused.d.ts +17 -0
  85. package/lib/share/ui/SealedLinkRefused.d.ts.map +1 -0
  86. package/lib/share/ui/SealedLinkRefused.js +23 -0
  87. package/lib/share/ui/SealedLinkRefused.js.map +1 -0
  88. package/lib/share/ui/ShareConfigOptions.d.ts +12 -1
  89. package/lib/share/ui/ShareConfigOptions.d.ts.map +1 -1
  90. package/lib/share/ui/ShareConfigOptions.js +4 -3
  91. package/lib/share/ui/ShareConfigOptions.js.map +1 -1
  92. package/lib/share/ui/ShareDialogContent.d.ts.map +1 -1
  93. package/lib/share/ui/ShareDialogContent.js +20 -6
  94. package/lib/share/ui/ShareDialogContent.js.map +1 -1
  95. package/lib/share/ui/ShareLinkBar.d.ts +5 -0
  96. package/lib/share/ui/ShareLinkBar.d.ts.map +1 -1
  97. package/lib/share/ui/ShareLinkBar.js +4 -4
  98. package/lib/share/ui/ShareLinkBar.js.map +1 -1
  99. package/lib/share/ui/index.d.ts +2 -0
  100. package/lib/share/ui/index.d.ts.map +1 -1
  101. package/lib/share/ui/index.js +1 -0
  102. package/lib/share/ui/index.js.map +1 -1
  103. package/lib/share/ui/useSealedUrl.d.ts +17 -0
  104. package/lib/share/ui/useSealedUrl.d.ts.map +1 -0
  105. package/lib/share/ui/useSealedUrl.js +31 -0
  106. package/lib/share/ui/useSealedUrl.js.map +1 -0
  107. package/package.json +4 -1
  108. package/src/host/core/connectHost.ts +240 -0
  109. package/src/host/core/hostStructure.ts +129 -0
  110. package/src/host/core/index.ts +46 -0
  111. package/src/host/core/kinds.ts +128 -0
  112. package/src/host/core/protocol.ts +197 -0
  113. package/src/host/testing/sciPeaks.ts +66 -0
  114. package/src/host/testing/standInHost.ts +116 -0
  115. package/src/host/ui/index.ts +2 -0
  116. package/src/host/ui/useHostConnection.ts +85 -0
  117. package/src/host-testing.ts +18 -0
  118. package/src/host.ts +11 -0
  119. package/src/language/ui/siteLanguageContext.ts +2 -1
  120. package/src/locales/de.json +5 -0
  121. package/src/locales/en.json +5 -0
  122. package/src/locales/es.json +5 -0
  123. package/src/locales/fr.json +5 -0
  124. package/src/locales/it.json +5 -0
  125. package/src/router/core/history.ts +9 -15
  126. package/src/router/core/index.ts +8 -0
  127. package/src/router/core/pageAddress.ts +110 -0
  128. package/src/share/core/index.ts +10 -0
  129. package/src/share/core/preferences.ts +10 -10
  130. package/src/share/core/seal.ts +233 -0
  131. package/src/share/core/sealedAddress.ts +48 -0
  132. package/src/share/ui/SealedLinkRefused.tsx +43 -0
  133. package/src/share/ui/ShareConfigOptions.tsx +23 -1
  134. package/src/share/ui/ShareDialogContent.tsx +37 -6
  135. package/src/share/ui/ShareLinkBar.tsx +9 -1
  136. package/src/share/ui/index.ts +2 -0
  137. package/src/share/ui/useSealedUrl.ts +40 -0
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The cheminfo host protocol, version 1: what a page that frames a tool — an
3
+ * electronic lab notebook, a course — and the tool say to each other.
4
+ *
5
+ * Two messages travel between the windows — `hello`, from the tool, and
6
+ * `connect`, the host's answer carrying a private `MessagePort` — and everything
7
+ * else goes through that port: the host's requests (`load`, `get`) and the
8
+ * tool's `result` for each. This module only spells the messages, and reads
9
+ * what arrives as the untrusted input it is.
10
+ */
11
+
12
+ /** The version a tool speaks; a `connect` naming another is ignored. */
13
+ export const PROTOCOL_VERSION = 1;
14
+
15
+ /** Posted to the parent frame or the opener at start-up: who the tool is. */
16
+ export const HELLO_TYPE = 'cheminfo:hello';
17
+
18
+ /** The host's answer to `hello`, carrying the port the rest goes through. */
19
+ export const CONNECT_TYPE = 'cheminfo:connect';
20
+
21
+ /** How many items one request may carry; the rest are dropped. */
22
+ export const MAX_ITEMS = 64;
23
+
24
+ /** How long a label may be, in characters; a longer one is cut. */
25
+ export const MAX_LABEL_LENGTH = 80;
26
+
27
+ /** One typed value crossing the frame boundary. */
28
+ export interface HostItem {
29
+ /** A kind name, e.g. `'chem.molfile.2d'`. */
30
+ kind: string;
31
+ /** Checked against the kind before anything reads it. */
32
+ value: unknown;
33
+ /**
34
+ * The host's name for it, opaque here, e.g. an attachment.
35
+ * @default undefined
36
+ */
37
+ id?: string;
38
+ /**
39
+ * The tool input it is meant for.
40
+ * @default undefined — any input that accepts its kind
41
+ */
42
+ key?: string;
43
+ /**
44
+ * What a notice calls it, e.g. `'Ethylbenzene'`.
45
+ * @default undefined
46
+ */
47
+ label?: string;
48
+ }
49
+
50
+ /** One value the tool takes. */
51
+ export interface ToolInput {
52
+ key: string;
53
+ /** Kinds in order of preference; a category accepts every kind under it. */
54
+ kinds: readonly string[];
55
+ }
56
+
57
+ /** One value the tool can hand back. */
58
+ export interface ToolOutput {
59
+ key: string;
60
+ kind: string;
61
+ }
62
+
63
+ /** What the tool is and takes, as `hello` announces it. */
64
+ export interface ToolInterface {
65
+ name: string;
66
+ inputs: readonly ToolInput[];
67
+ outputs: readonly ToolOutput[];
68
+ }
69
+
70
+ /** What `hello` carries: public, readable by any origin. */
71
+ export interface HelloMessage {
72
+ type: typeof HELLO_TYPE;
73
+ version: typeof PROTOCOL_VERSION;
74
+ tool: ToolInterface & {
75
+ /** The tool's origin, e.g. `'https://3d.cheminfo.org'`. */
76
+ origin: string;
77
+ /** The build that is running, as the About states it, or `null`. */
78
+ build: unknown;
79
+ };
80
+ }
81
+
82
+ /** `load`: the host's data, for one document. */
83
+ export interface LoadRequest {
84
+ type: 'load';
85
+ id: string;
86
+ /** What the data belongs to; the same document twice is applied once. */
87
+ document: string;
88
+ items: HostItem[];
89
+ }
90
+
91
+ /** `get`: the current value of some outputs. */
92
+ export interface GetRequest {
93
+ type: 'get';
94
+ id: string;
95
+ keys: string[];
96
+ }
97
+
98
+ /** A request the host sends through the port. */
99
+ export type HostRequest = LoadRequest | GetRequest;
100
+
101
+ /** Why a request could not be served. */
102
+ export type HostErrorCode = 'invalid' | 'unreadable' | 'no-input' | 'no-output';
103
+
104
+ /** What a failed request is answered with. */
105
+ export interface HostError {
106
+ code: HostErrorCode;
107
+ /** One sentence, in English. */
108
+ message: string;
109
+ /**
110
+ * What the schema found, as `'chem.ocl.id /idCode: must be string'`.
111
+ * @default undefined
112
+ */
113
+ errors?: string[];
114
+ }
115
+
116
+ /** Which item an input took. */
117
+ export interface UsedItem {
118
+ key: string;
119
+ kind: string;
120
+ }
121
+
122
+ /** The answer to one request, carrying its `id`. */
123
+ export type ResultMessage =
124
+ | { type: 'result'; id: string; ok: true; used: UsedItem[] }
125
+ | { type: 'result'; id: string; ok: false; error: HostError };
126
+
127
+ /**
128
+ * Read a message that arrived through the port.
129
+ *
130
+ * Anything that is not a well-formed `load` or `get` is `null`, and goes
131
+ * unanswered: an unknown request is a newer host talking, not an error. An item
132
+ * without a string `kind` is dropped, and a label is cut to
133
+ * {@link MAX_LABEL_LENGTH}.
134
+ * @param data - What the port delivered.
135
+ * @returns The request, or `null`.
136
+ */
137
+ export function parseRequest(data: unknown): HostRequest | null {
138
+ if (!isRecord(data) || typeof data.id !== 'string') return null;
139
+ if (data.type === 'load') {
140
+ if (typeof data.document !== 'string' || !Array.isArray(data.items)) {
141
+ return null;
142
+ }
143
+ return {
144
+ type: 'load',
145
+ id: data.id,
146
+ document: data.document,
147
+ items: readItems(data.items),
148
+ };
149
+ }
150
+ if (data.type === 'get') {
151
+ return { type: 'get', id: data.id, keys: readStrings(data.keys) };
152
+ }
153
+ return null;
154
+ }
155
+
156
+ /**
157
+ * Whether a window message is the host's answer to `hello`, in this version.
158
+ * @param data - What the window delivered.
159
+ * @returns `true` for a `connect` of {@link PROTOCOL_VERSION}.
160
+ */
161
+ export function isConnect(data: unknown): boolean {
162
+ return (
163
+ isRecord(data) &&
164
+ data.type === CONNECT_TYPE &&
165
+ data.version === PROTOCOL_VERSION
166
+ );
167
+ }
168
+
169
+ function readItems(values: readonly unknown[]): HostItem[] {
170
+ const items: HostItem[] = [];
171
+ const count = Math.min(values.length, MAX_ITEMS);
172
+ for (let index = 0; index < count; index++) {
173
+ const value = values[index];
174
+ if (!isRecord(value) || typeof value.kind !== 'string') continue;
175
+ const item: HostItem = { kind: value.kind, value: value.value };
176
+ if (typeof value.id === 'string') item.id = value.id;
177
+ if (typeof value.key === 'string') item.key = value.key;
178
+ if (typeof value.label === 'string') {
179
+ item.label = value.label.slice(0, MAX_LABEL_LENGTH);
180
+ }
181
+ items.push(item);
182
+ }
183
+ return items;
184
+ }
185
+
186
+ function readStrings(value: unknown): string[] {
187
+ if (!Array.isArray(value)) return [];
188
+ const strings: string[] = [];
189
+ for (const entry of value) {
190
+ if (typeof entry === 'string') strings.push(entry);
191
+ }
192
+ return strings;
193
+ }
194
+
195
+ function isRecord(value: unknown): value is Record<string, unknown> {
196
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
197
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * What SciPeaks sends a tool, byte for byte: the `load` its tool tab —
3
+ * visualizer-helper's `eln/toolTab` — writes for the ethylbenzene sample its own
4
+ * tests read (`eln/__tests__/data/sample.json`). Both repositories pin this
5
+ * message, so a change on either side that the other does not expect fails a
6
+ * test rather than a notebook.
7
+ *
8
+ * The sample is a real one, and so are its quirks: its molfile is V2000 with
9
+ * every atom at the origin, its title is blank so the label is its first name,
10
+ * and its formula travels as a kind no structure input reads.
11
+ */
12
+
13
+ import type { LoadRequest } from '../core/protocol.ts';
14
+
15
+ /** How SciPeaks' tool tab introduces itself in `connect`. */
16
+ export const SCIPEAKS_HOST_NAME = 'SciPeaks';
17
+
18
+ /** The molfile SciPeaks stores for ethylbenzene: every atom at the origin. */
19
+ export const SCIPEAKS_ETHYLBENZENE_MOLFILE = [
20
+ '',
21
+ 'Actelion Java MolfileCreator 1.0',
22
+ '',
23
+ ' 8 8 0 0 0 0 0 0 0 0999 V2000',
24
+ ' 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0',
25
+ ' 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0',
26
+ ' 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0',
27
+ ' 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0',
28
+ ' 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0',
29
+ ' 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0',
30
+ ' 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0',
31
+ ' 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0',
32
+ ' 1 2 1 0 0 0 0',
33
+ ' 1 3 1 0 0 0 0',
34
+ ' 2 4 2 0 0 0 0',
35
+ ' 2 5 1 0 0 0 0',
36
+ ' 4 6 1 0 0 0 0',
37
+ ' 5 7 2 0 0 0 0',
38
+ ' 6 8 2 0 0 0 0',
39
+ ' 7 8 1 0 0 0 0',
40
+ 'M END',
41
+ '',
42
+ ].join('\n');
43
+
44
+ /** The first `load` the tool tab sends for that sample, with `accept`. */
45
+ export const SCIPEAKS_ETHYLBENZENE_LOAD: LoadRequest & { accept: string[] } = {
46
+ type: 'load',
47
+ id: 'load-1',
48
+ document: 'eln:1315a6d895f431497538cb6d834feab5',
49
+ items: [
50
+ {
51
+ kind: 'chem.molfile.2d',
52
+ value: { molfile: SCIPEAKS_ETHYLBENZENE_MOLFILE },
53
+ label: 'Ethylbenzene',
54
+ },
55
+ {
56
+ kind: 'chem.ocl.id',
57
+ value: {
58
+ idCode: 'daD@@DjUZxHH@@',
59
+ coordinates: String.raw`!BbOvw?_x@?g?~_{\Bm?t`,
60
+ },
61
+ label: 'Ethylbenzene',
62
+ },
63
+ { kind: 'chem.mf', value: { mf: 'C8H10' } },
64
+ ],
65
+ accept: [],
66
+ };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * A whole host, for a tool's end-to-end tests: a page on another origin that
3
+ * frames the tool, answers its `hello` with a private port, sends it requests,
4
+ * and records what comes back.
5
+ *
6
+ * It is written the way an electronic lab notebook would write one: it checks
7
+ * that `hello` came from its frame and from the tool's origin, and connects
8
+ * only then. A real server serves it on `127.0.0.1` while the tool runs on
9
+ * `localhost`, so every message crosses a real origin boundary — and it has to
10
+ * be a real server: Chrome takes a page that Playwright answers itself for a
11
+ * public one, and a public page may not frame a local site.
12
+ */
13
+
14
+ import type { Server } from 'node:http';
15
+ import { createServer } from 'node:http';
16
+ import type { AddressInfo } from 'node:net';
17
+
18
+ import type { HelloMessage } from '../core/protocol.ts';
19
+
20
+ /** A running stand-in host. */
21
+ export interface StandInHost {
22
+ /** Where the host page is served, e.g. `http://127.0.0.1:53211`. */
23
+ origin: string;
24
+ /**
25
+ * The address of a host page that frames `toolUrl` in `#tool` and, once the
26
+ * tool has said `hello`, sends `requests` through the port it hands over.
27
+ * @param toolUrl - The tool's address, `?embed=1` included.
28
+ * @param requests - What to send on the port, in order.
29
+ * @returns The address to open.
30
+ */
31
+ pageUrl: (toolUrl: string, requests: readonly unknown[]) => string;
32
+ /** Stop serving. */
33
+ close: () => Promise<void>;
34
+ }
35
+
36
+ /** What the host page recorded, read with {@link readStandInHostLog}. */
37
+ export interface StandInHostLog {
38
+ /** The tool's `hello`, or `null` before it came. */
39
+ hello: HelloMessage | null;
40
+ /** Every message the tool sent on the port, in order. */
41
+ results: unknown[];
42
+ }
43
+
44
+ /**
45
+ * Serve the host page on a free port of `127.0.0.1`.
46
+ * @returns The host; close it when the tests are done.
47
+ */
48
+ export async function startStandInHost(): Promise<StandInHost> {
49
+ const server: Server = createServer((_request, response) => {
50
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
51
+ response.end(HOST_PAGE);
52
+ });
53
+ await new Promise<void>((resolve) => {
54
+ server.listen(0, '127.0.0.1', resolve);
55
+ });
56
+ const { port } = server.address() as AddressInfo;
57
+ const origin = `http://127.0.0.1:${port}`;
58
+
59
+ return {
60
+ origin,
61
+ pageUrl: (toolUrl, requests) => {
62
+ const setup = encodeURIComponent(JSON.stringify({ toolUrl, requests }));
63
+ return `${origin}/?setup=${setup}`;
64
+ },
65
+ close: () =>
66
+ new Promise<void>((resolve, reject) => {
67
+ server.close((error) => {
68
+ if (error) reject(error);
69
+ else resolve();
70
+ });
71
+ }),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Read what the host page recorded. Runs in the page: hand it to Playwright's
77
+ * `page.evaluate`, as `page.evaluate(readStandInHostLog)`.
78
+ * @returns The log.
79
+ */
80
+ export function readStandInHostLog(): StandInHostLog {
81
+ return (globalThis as unknown as { hostLog: StandInHostLog }).hostLog;
82
+ }
83
+
84
+ /**
85
+ * The page. Its setup is its own `?setup=`, so one page serves every test, and
86
+ * it listens before it frames, so the tool's `hello` cannot be missed.
87
+ */
88
+ const HOST_PAGE = `<!doctype html>
89
+ <html lang="en">
90
+ <head><meta charset="utf-8"><title>Stand-in host</title></head>
91
+ <body>
92
+ <iframe id="tool" title="The framed tool" style="width: 1280px; height: 800px"></iframe>
93
+ <script>
94
+ const { toolUrl, requests } = JSON.parse(new URLSearchParams(location.search).get('setup'));
95
+ const toolOrigin = new URL(toolUrl).origin;
96
+ const frame = document.querySelector('#tool');
97
+ globalThis.hostLog = { hello: null, results: [] };
98
+ globalThis.addEventListener('message', (event) => {
99
+ if (event.source !== frame.contentWindow || event.origin !== toolOrigin) return;
100
+ if (event.data?.type !== 'cheminfo:hello') return;
101
+ globalThis.hostLog.hello = event.data;
102
+ const channel = new MessageChannel();
103
+ channel.port1.onmessage = (answer) => {
104
+ globalThis.hostLog.results.push(answer.data);
105
+ };
106
+ frame.contentWindow.postMessage(
107
+ { type: 'cheminfo:connect', version: 1, host: { name: 'Stand-in host' } },
108
+ toolOrigin,
109
+ [channel.port2],
110
+ );
111
+ for (const request of requests) channel.port1.postMessage(request);
112
+ });
113
+ frame.src = toolUrl;
114
+ </script>
115
+ </body>
116
+ </html>`;
@@ -0,0 +1,2 @@
1
+ export type { HostHandlers } from './useHostConnection.ts';
2
+ export { useHostConnection } from './useHostConnection.ts';
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Connect a tool to the page that frames it, when there is one.
3
+ *
4
+ * The protocol and its schemas are fetched only then, so a visitor on the site
5
+ * itself never downloads them.
6
+ */
7
+
8
+ import { useEffect, useRef } from 'react';
9
+
10
+ import type { LoadedInput } from '../core/connectHost.ts';
11
+ import type { ToolInterface } from '../core/protocol.ts';
12
+
13
+ /** What a tool does with what its host sends, and what it says about itself. */
14
+ export interface HostHandlers {
15
+ /**
16
+ * Show the inputs of a new document. Throwing means they could not be read:
17
+ * the error's message goes back to the host, and nothing should have changed
18
+ * on screen.
19
+ */
20
+ load: (inputs: readonly LoadedInput[]) => void;
21
+ /**
22
+ * The build that is running — `BUILD_INFO` from `react-cheminfo/build-info`
23
+ * — so a host can record which version produced what it stores.
24
+ * @default null
25
+ */
26
+ build?: unknown;
27
+ }
28
+
29
+ /** Once per page: React may mount the caller twice, the host is greeted once. */
30
+ let started = false;
31
+
32
+ /**
33
+ * Introduce the tool to its parent frame, or to the window that opened it, and
34
+ * serve that host for the life of the page.
35
+ *
36
+ * Call it after the hook that applies the address: effects run in order, so
37
+ * the address has been applied by the time the host is greeted, and a host's
38
+ * structure replaces the one the frame's link named rather than the reverse.
39
+ * The latest `load` is always the one called, so it may close over state.
40
+ * @param tool - What the tool takes and gives.
41
+ * @param handlers - See {@link HostHandlers}.
42
+ */
43
+ export function useHostConnection(
44
+ tool: ToolInterface,
45
+ handlers: HostHandlers,
46
+ ): void {
47
+ const latest = useRef(handlers);
48
+ useEffect(() => {
49
+ latest.current = handlers;
50
+ });
51
+
52
+ useEffect(() => {
53
+ if (started) return;
54
+ const host = hostWindow();
55
+ if (host === null) return;
56
+ started = true;
57
+ import('../core/connectHost.ts')
58
+ .then(({ connectHost }) => {
59
+ connectHost({
60
+ host,
61
+ listen: (listener) => {
62
+ globalThis.addEventListener('message', listener);
63
+ return () => {
64
+ globalThis.removeEventListener('message', listener);
65
+ };
66
+ },
67
+ tool,
68
+ origin: globalThis.location.origin,
69
+ build: latest.current.build ?? null,
70
+ load: (inputs) => {
71
+ latest.current.load(inputs);
72
+ },
73
+ });
74
+ })
75
+ .catch(() => {
76
+ started = false;
77
+ });
78
+ }, [tool]);
79
+ }
80
+
81
+ function hostWindow(): Window | null {
82
+ const { parent, opener } = globalThis.window;
83
+ if (parent !== globalThis.window) return parent;
84
+ return (opener as Window | null) ?? null;
85
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A stand-in host for a tool's end-to-end tests, and the messages SciPeaks
3
+ * sends. Node only: it serves a page.
4
+ */
5
+
6
+ export type {
7
+ StandInHost,
8
+ StandInHostLog,
9
+ } from './host/testing/standInHost.ts';
10
+ export {
11
+ readStandInHostLog,
12
+ startStandInHost,
13
+ } from './host/testing/standInHost.ts';
14
+ export {
15
+ SCIPEAKS_ETHYLBENZENE_LOAD,
16
+ SCIPEAKS_ETHYLBENZENE_MOLFILE,
17
+ SCIPEAKS_HOST_NAME,
18
+ } from './host/testing/sciPeaks.ts';
package/src/host.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The host protocol: a page that frames a tool — an electronic lab notebook, a
3
+ * course — hands it typed data, checked against pipeline.cheminfo.org's kinds.
4
+ *
5
+ * Its own door because the schemas are checked with ajv. `useHostConnection`
6
+ * fetches ajv and the connection only when the page is framed, so a site that
7
+ * calls it ships nothing extra to a visitor who opened it directly.
8
+ */
9
+
10
+ export * from './host/core/index.ts';
11
+ export * from './host/ui/index.ts';
@@ -1,5 +1,6 @@
1
1
  import { createContext, use } from 'react';
2
2
 
3
+ import { pageLocation } from '../../router/core/pageAddress.ts';
3
4
  import { readLanguageParam } from '../core/languageParam.ts';
4
5
 
5
6
  /** What a site declares the page is being read in. */
@@ -16,5 +17,5 @@ export const SiteLanguageContext = createContext<string | undefined>(undefined);
16
17
  export function useSiteLanguage(): string | undefined {
17
18
  const declared = use(SiteLanguageContext);
18
19
  if (declared !== undefined) return declared;
19
- return readLanguageParam(globalThis.location?.search ?? '');
20
+ return readLanguageParam(pageLocation()?.search ?? '');
20
21
  }
@@ -54,6 +54,8 @@
54
54
  "share.layout": "Darstellung",
55
55
  "share.embed": "In eine andere Seite einbetten",
56
56
  "share.embedHint": "Lässt Kopfzeile und Navigation der Website weg, damit die Seite in einer Seite von Ihnen sitzt.",
57
+ "share.protect": "Diesen Link schützen",
58
+ "share.protectHint": "Die Klasse kann weder lesen noch ändern, was der Link einstellt, und die Adresse der Seite bleibt fest. Die Seite wird eingebettet.",
57
59
  "share.on": "Ein",
58
60
  "share.off": "Aus",
59
61
  "share.showOnPage": "Auf der Seite zeigen",
@@ -76,6 +78,9 @@
76
78
  "share.copyIframe": "Iframe kopieren",
77
79
  "share.copyIframeHelpTitle": "Die Seite in Ihre einbauen",
78
80
  "share.copyIframeHelp": "Ein HTML-Schnipsel, das dort eingefügt wird, wo das HTML Ihrer Seite bearbeitet wird: das Werkzeug läuft dann darin.",
81
+ "seal.refusedTitle": "Dieser Link lässt sich nicht öffnen",
82
+ "seal.refusedBody": "Er wurde nach dem Erstellen verändert oder ist unvollständig, daher wurde nichts geöffnet. Bitten Sie erneut um den Link.",
83
+ "seal.openWithout": "Das Werkzeug ohne den Link öffnen",
79
84
  "cite.button": "Zitieren",
80
85
  "cite.copyReferenceAs": "Literaturangabe kopieren als",
81
86
  "cite.pleaseCiteBoth": "Bitte zitieren Sie beide Arbeiten",
@@ -54,6 +54,8 @@
54
54
  "share.layout": "Layout",
55
55
  "share.embed": "Embed in another page",
56
56
  "share.embedHint": "Drops the site header and its navigation, so the page sits inside a page of your own.",
57
+ "share.protect": "Protect this link",
58
+ "share.protectHint": "The class can neither read nor change what the link sets up, and the page's address stays fixed. The page is embedded.",
57
59
  "share.on": "On",
58
60
  "share.off": "Off",
59
61
  "share.showOnPage": "Show on the page",
@@ -76,6 +78,9 @@
76
78
  "share.copyIframe": "Copy the iframe",
77
79
  "share.copyIframeHelpTitle": "Put the page inside yours",
78
80
  "share.copyIframeHelp": "A snippet of HTML to paste where your own page's HTML is edited: the tool then runs inside it.",
81
+ "seal.refusedTitle": "This link cannot be opened",
82
+ "seal.refusedBody": "It was changed after it was made, or it is incomplete, so nothing was opened. Ask for the link again.",
83
+ "seal.openWithout": "Open the tool without it",
79
84
  "cite.button": "Cite",
80
85
  "cite.copyReferenceAs": "Copy the reference as",
81
86
  "cite.pleaseCiteBoth": "Please cite both works",
@@ -54,6 +54,8 @@
54
54
  "share.layout": "Disposición",
55
55
  "share.embed": "Incrustar en otra página",
56
56
  "share.embedHint": "Quita la cabecera del sitio y su navegación, para que la página quede dentro de una página suya.",
57
+ "share.protect": "Proteger este enlace",
58
+ "share.protectHint": "La clase no puede leer ni cambiar lo que configura el enlace, y la dirección de la página queda fija. La página se integra.",
57
59
  "share.on": "Sí",
58
60
  "share.off": "No",
59
61
  "share.showOnPage": "Mostrar en la página",
@@ -76,6 +78,9 @@
76
78
  "share.copyIframe": "Copiar el iframe",
77
79
  "share.copyIframeHelpTitle": "Poner la página dentro de la tuya",
78
80
  "share.copyIframeHelp": "Un fragmento de HTML para pegar donde se edita el HTML de tu página: la herramienta funciona dentro de él.",
81
+ "seal.refusedTitle": "Este enlace no se puede abrir",
82
+ "seal.refusedBody": "Se modificó después de crearse, o está incompleto, así que no se abrió nada. Pide el enlace de nuevo.",
83
+ "seal.openWithout": "Abrir la herramienta sin el enlace",
79
84
  "cite.button": "Citar",
80
85
  "cite.copyReferenceAs": "Copiar la referencia como",
81
86
  "cite.pleaseCiteBoth": "Por favor, cite ambos trabajos",
@@ -54,6 +54,8 @@
54
54
  "share.layout": "Disposition",
55
55
  "share.embed": "Intégrer dans une autre page",
56
56
  "share.embedHint": "Retire l'en-tête du site et sa navigation, pour que la page prenne place dans une page à vous.",
57
+ "share.protect": "Protéger ce lien",
58
+ "share.protectHint": "La classe ne peut ni lire ni modifier ce que le lien règle, et l'adresse de la page reste fixe. La page est intégrée.",
57
59
  "share.on": "Oui",
58
60
  "share.off": "Non",
59
61
  "share.showOnPage": "Afficher sur la page",
@@ -76,6 +78,9 @@
76
78
  "share.copyIframe": "Copier l'iframe",
77
79
  "share.copyIframeHelpTitle": "Mettre la page dans la vôtre",
78
80
  "share.copyIframeHelp": "Un extrait de HTML à coller là où le HTML de votre page s'édite : l'outil s'exécute alors à l'intérieur.",
81
+ "seal.refusedTitle": "Ce lien ne peut pas être ouvert",
82
+ "seal.refusedBody": "Il a été modifié après sa création, ou il est incomplet : rien n'a été ouvert. Demandez à nouveau le lien.",
83
+ "seal.openWithout": "Ouvrir l'outil sans ce lien",
79
84
  "cite.button": "Citer",
80
85
  "cite.copyReferenceAs": "Copier la référence en",
81
86
  "cite.pleaseCiteBoth": "Merci de citer les deux travaux",
@@ -54,6 +54,8 @@
54
54
  "share.layout": "Disposizione",
55
55
  "share.embed": "Incorporare in un'altra pagina",
56
56
  "share.embedHint": "Toglie l'intestazione del sito e la sua navigazione, così la pagina sta dentro una pagina propria.",
57
+ "share.protect": "Proteggi questo link",
58
+ "share.protectHint": "La classe non può né leggere né modificare ciò che il link imposta, e l'indirizzo della pagina resta fisso. La pagina viene incorporata.",
57
59
  "share.on": "Sì",
58
60
  "share.off": "No",
59
61
  "share.showOnPage": "Mostra nella pagina",
@@ -76,6 +78,9 @@
76
78
  "share.copyIframe": "Copia l'iframe",
77
79
  "share.copyIframeHelpTitle": "Mettere la pagina dentro la tua",
78
80
  "share.copyIframeHelp": "Un frammento di HTML da incollare dove si modifica l'HTML della tua pagina: lo strumento funziona al suo interno.",
81
+ "seal.refusedTitle": "Questo link non può essere aperto",
82
+ "seal.refusedBody": "È stato modificato dopo la creazione, o è incompleto, quindi non si è aperto nulla. Chiedi di nuovo il link.",
83
+ "seal.openWithout": "Apri lo strumento senza il link",
79
84
  "cite.button": "Cita",
80
85
  "cite.copyReferenceAs": "Copia il riferimento come",
81
86
  "cite.pleaseCiteBoth": "Si prega di citare entrambi i lavori",
@@ -2,13 +2,15 @@
2
2
  * The tab router bound to the browser: read the address on screen, write a new
3
3
  * one through the History API, and hear every move.
4
4
  *
5
- * The router itself stays pure. Everything here reads `globalThis.location` and
6
- * writes `globalThis.history`, and does nothing where neither exists, so a
7
- * prerender script can import a module that uses it.
5
+ * The router itself stays pure. Everything here reads and writes the address
6
+ * through `pageLocation` and `pageHistory` — the browser's, or the one a sealed
7
+ * link holds in memory — and does nothing where neither exists, so a prerender
8
+ * script can import a module that uses it.
8
9
  */
9
10
 
10
11
  import { EMBED_PARAM, HIDE_PARAM } from '../../share/core/config.ts';
11
12
 
13
+ import { pageHistory, pageLocation } from './pageAddress.ts';
12
14
  import type { QueryEntry } from './query.ts';
13
15
  import { formatQueryEntries } from './query.ts';
14
16
  import type { TabRoute, TabRouteInput, TabRouter } from './tabRouter.ts';
@@ -34,7 +36,7 @@ export function readRoute<Tab extends string>(
34
36
  const address = currentAddress();
35
37
  const adopted = router.legacyAddress(address);
36
38
  if (adopted === null) return router.parse(address);
37
- const history = browserHistory();
39
+ const history = pageHistory();
38
40
  history?.replaceState(history.state, '', adopted);
39
41
  globalThis.dispatchEvent?.(new Event(ROUTE_CHANGE_EVENT));
40
42
  return router.parse(adopted);
@@ -78,8 +80,8 @@ export function writeRoute<Tab extends string>(
78
80
  ): string {
79
81
  const current = currentAddress();
80
82
  const address = withKeptParams(router, route, current, options.keep ?? []);
81
- const location = browserLocation();
82
- const history = browserHistory();
83
+ const location = pageLocation();
84
+ const history = pageHistory();
83
85
  if (location === undefined || history === undefined) return address;
84
86
 
85
87
  const shown =
@@ -126,7 +128,7 @@ export function subscribeToRoute<Tab extends string>(
126
128
  * @returns It, or `/` where there is no browser.
127
129
  */
128
130
  export function currentAddress(): string {
129
- const location = browserLocation();
131
+ const location = pageLocation();
130
132
  if (location === undefined) return '/';
131
133
  return `${location.pathname}${location.search}${location.hash}`;
132
134
  }
@@ -150,11 +152,3 @@ function withKeptParams<Tab extends string>(
150
152
  const separator = target.includes('?') ? '&' : '?';
151
153
  return `${target}${separator}${formatQueryEntries(kept)}`;
152
154
  }
153
-
154
- function browserLocation(): Location | undefined {
155
- return globalThis.location;
156
- }
157
-
158
- function browserHistory(): History | undefined {
159
- return globalThis.history;
160
- }