react-cheminfo 0.23.0 → 0.24.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 (260) hide show
  1. package/README.md +39 -26
  2. package/lib/chrome/ui/HeaderToggle.d.ts +52 -0
  3. package/lib/chrome/ui/HeaderToggle.d.ts.map +1 -0
  4. package/lib/chrome/ui/HeaderToggle.js +31 -0
  5. package/lib/chrome/ui/HeaderToggle.js.map +1 -0
  6. package/lib/chrome/ui/index.d.ts +2 -0
  7. package/lib/chrome/ui/index.d.ts.map +1 -1
  8. package/lib/chrome/ui/index.js +1 -0
  9. package/lib/chrome/ui/index.js.map +1 -1
  10. package/lib/citation/core/gfn2Paper.d.ts +10 -0
  11. package/lib/citation/core/gfn2Paper.d.ts.map +1 -0
  12. package/lib/citation/core/gfn2Paper.js +28 -0
  13. package/lib/citation/core/gfn2Paper.js.map +1 -0
  14. package/lib/citation/core/index.d.ts +1 -0
  15. package/lib/citation/core/index.d.ts.map +1 -1
  16. package/lib/citation/core/index.js +1 -0
  17. package/lib/citation/core/index.js.map +1 -1
  18. package/lib/clipboard/ui/ClickToCopy.d.ts +8 -7
  19. package/lib/clipboard/ui/ClickToCopy.d.ts.map +1 -1
  20. package/lib/clipboard/ui/ClickToCopy.js +3 -3
  21. package/lib/clipboard/ui/ClickToCopy.js.map +1 -1
  22. package/lib/conformers.d.ts +27 -0
  23. package/lib/conformers.d.ts.map +1 -0
  24. package/lib/conformers.js +19 -0
  25. package/lib/conformers.js.map +1 -0
  26. package/lib/core.d.ts +1 -0
  27. package/lib/core.d.ts.map +1 -1
  28. package/lib/core.js +1 -0
  29. package/lib/core.js.map +1 -1
  30. package/lib/credits/core/credits.d.ts +12 -0
  31. package/lib/credits/core/credits.d.ts.map +1 -1
  32. package/lib/credits/core/credits.js +14 -0
  33. package/lib/credits/core/credits.js.map +1 -1
  34. package/lib/ecosystem/core/index.d.ts +1 -1
  35. package/lib/ecosystem/core/index.d.ts.map +1 -1
  36. package/lib/ecosystem/core/index.js.map +1 -1
  37. package/lib/ecosystem/core/sites.d.ts +11 -1
  38. package/lib/ecosystem/core/sites.d.ts.map +1 -1
  39. package/lib/ecosystem/core/sites.js +7 -2
  40. package/lib/ecosystem/core/sites.js.map +1 -1
  41. package/lib/ecosystem/ui/EcosystemLinks.d.ts.map +1 -1
  42. package/lib/ecosystem/ui/EcosystemLinks.js +3 -1
  43. package/lib/ecosystem/ui/EcosystemLinks.js.map +1 -1
  44. package/lib/ecosystem/ui/SiteTile.d.ts.map +1 -1
  45. package/lib/ecosystem/ui/SiteTile.js +3 -1
  46. package/lib/ecosystem/ui/SiteTile.js.map +1 -1
  47. package/lib/format/core/index.d.ts +1 -1
  48. package/lib/format/core/index.d.ts.map +1 -1
  49. package/lib/format/core/index.js +1 -1
  50. package/lib/format/core/index.js.map +1 -1
  51. package/lib/format/core/words.d.ts +9 -0
  52. package/lib/format/core/words.d.ts.map +1 -1
  53. package/lib/format/core/words.js +16 -0
  54. package/lib/format/core/words.js.map +1 -1
  55. package/lib/language/core/index.d.ts +3 -0
  56. package/lib/language/core/index.d.ts.map +1 -0
  57. package/lib/language/core/index.js +3 -0
  58. package/lib/language/core/index.js.map +1 -0
  59. package/lib/language/core/languageName.d.ts +8 -0
  60. package/lib/language/core/languageName.d.ts.map +1 -0
  61. package/lib/language/core/languageName.js +16 -0
  62. package/lib/language/core/languageName.js.map +1 -0
  63. package/lib/language/core/languageParam.d.ts +35 -0
  64. package/lib/language/core/languageParam.d.ts.map +1 -0
  65. package/lib/language/core/languageParam.js +67 -0
  66. package/lib/language/core/languageParam.js.map +1 -0
  67. package/lib/language/ui/SiteLanguage.d.ts +20 -0
  68. package/lib/language/ui/SiteLanguage.d.ts.map +1 -0
  69. package/lib/language/ui/SiteLanguage.js +14 -0
  70. package/lib/language/ui/SiteLanguage.js.map +1 -0
  71. package/lib/language/ui/index.d.ts +4 -0
  72. package/lib/language/ui/index.d.ts.map +1 -0
  73. package/lib/language/ui/index.js +3 -0
  74. package/lib/language/ui/index.js.map +1 -0
  75. package/lib/language/ui/siteLanguageContext.d.ts +12 -0
  76. package/lib/language/ui/siteLanguageContext.d.ts.map +1 -0
  77. package/lib/language/ui/siteLanguageContext.js +19 -0
  78. package/lib/language/ui/siteLanguageContext.js.map +1 -0
  79. package/lib/orbital/core/atomicOrbitals.d.ts +9 -1
  80. package/lib/orbital/core/atomicOrbitals.d.ts.map +1 -1
  81. package/lib/orbital/core/atomicOrbitals.js +7 -0
  82. package/lib/orbital/core/atomicOrbitals.js.map +1 -1
  83. package/lib/orbital/core/hydrogenic.d.ts +2 -1
  84. package/lib/orbital/core/hydrogenic.d.ts.map +1 -1
  85. package/lib/orbital/core/hydrogenic.js +2 -1
  86. package/lib/orbital/core/hydrogenic.js.map +1 -1
  87. package/lib/orbital/core/index.d.ts +1 -0
  88. package/lib/orbital/core/index.d.ts.map +1 -1
  89. package/lib/orbital/core/index.js +1 -0
  90. package/lib/orbital/core/index.js.map +1 -1
  91. package/lib/orbital/core/slaterEnergy.d.ts +35 -0
  92. package/lib/orbital/core/slaterEnergy.d.ts.map +1 -0
  93. package/lib/orbital/core/slaterEnergy.js +66 -0
  94. package/lib/orbital/core/slaterEnergy.js.map +1 -0
  95. package/lib/shared/ui/MenuButton.d.ts +3 -2
  96. package/lib/shared/ui/MenuButton.d.ts.map +1 -1
  97. package/lib/shared/ui/MenuButton.js +1 -1
  98. package/lib/shared/ui/MenuButton.js.map +1 -1
  99. package/lib/structure/core/conformerMinimise.d.ts +36 -0
  100. package/lib/structure/core/conformerMinimise.d.ts.map +1 -0
  101. package/lib/structure/core/conformerMinimise.js +81 -0
  102. package/lib/structure/core/conformerMinimise.js.map +1 -0
  103. package/lib/structure/core/conformerMinimum.d.ts +62 -0
  104. package/lib/structure/core/conformerMinimum.d.ts.map +1 -0
  105. package/lib/structure/core/conformerMinimum.js +90 -0
  106. package/lib/structure/core/conformerMinimum.js.map +1 -0
  107. package/lib/structure/core/conformerOptions.d.ts +62 -0
  108. package/lib/structure/core/conformerOptions.d.ts.map +1 -0
  109. package/lib/structure/core/conformerOptions.js +83 -0
  110. package/lib/structure/core/conformerOptions.js.map +1 -0
  111. package/lib/structure/core/conformerRefine.d.ts +104 -0
  112. package/lib/structure/core/conformerRefine.d.ts.map +1 -0
  113. package/lib/structure/core/conformerRefine.js +130 -0
  114. package/lib/structure/core/conformerRefine.js.map +1 -0
  115. package/lib/structure/core/conformerRefineRank.d.ts +36 -0
  116. package/lib/structure/core/conformerRefineRank.d.ts.map +1 -0
  117. package/lib/structure/core/conformerRefineRank.js +69 -0
  118. package/lib/structure/core/conformerRefineRank.js.map +1 -0
  119. package/lib/structure/core/conformerSession.d.ts +50 -0
  120. package/lib/structure/core/conformerSession.d.ts.map +1 -0
  121. package/lib/structure/core/conformerSession.js +88 -0
  122. package/lib/structure/core/conformerSession.js.map +1 -0
  123. package/lib/structure/core/conformerShape.d.ts +36 -0
  124. package/lib/structure/core/conformerShape.d.ts.map +1 -0
  125. package/lib/structure/core/conformerShape.js +150 -0
  126. package/lib/structure/core/conformerShape.js.map +1 -0
  127. package/lib/structure/core/conformers.d.ts +107 -0
  128. package/lib/structure/core/conformers.d.ts.map +1 -0
  129. package/lib/structure/core/conformers.js +149 -0
  130. package/lib/structure/core/conformers.js.map +1 -0
  131. package/lib/structure/core/geometryRelaxer.d.ts +66 -0
  132. package/lib/structure/core/geometryRelaxer.d.ts.map +1 -0
  133. package/lib/structure/core/geometryRelaxer.js +2 -0
  134. package/lib/structure/core/geometryRelaxer.js.map +1 -0
  135. package/lib/structure/core/index.d.ts +4 -0
  136. package/lib/structure/core/index.d.ts.map +1 -1
  137. package/lib/structure/core/index.js +2 -0
  138. package/lib/structure/core/index.js.map +1 -1
  139. package/lib/structure/core/moleculeCoordinates.d.ts +31 -0
  140. package/lib/structure/core/moleculeCoordinates.d.ts.map +1 -0
  141. package/lib/structure/core/moleculeCoordinates.js +80 -0
  142. package/lib/structure/core/moleculeCoordinates.js.map +1 -0
  143. package/lib/structure/core/molfileExport.d.ts +42 -0
  144. package/lib/structure/core/molfileExport.d.ts.map +1 -0
  145. package/lib/structure/core/molfileExport.js +47 -0
  146. package/lib/structure/core/molfileExport.js.map +1 -0
  147. package/lib/structure/core/oclResources.d.ts +12 -0
  148. package/lib/structure/core/oclResources.d.ts.map +1 -0
  149. package/lib/structure/core/oclResources.js +35 -0
  150. package/lib/structure/core/oclResources.js.map +1 -0
  151. package/lib/translate/core/catalog.d.ts +61 -0
  152. package/lib/translate/core/catalog.d.ts.map +1 -0
  153. package/lib/translate/core/catalog.js +49 -0
  154. package/lib/translate/core/catalog.js.map +1 -0
  155. package/lib/translate/core/checkTranslation.d.ts +32 -0
  156. package/lib/translate/core/checkTranslation.d.ts.map +1 -0
  157. package/lib/translate/core/checkTranslation.js +98 -0
  158. package/lib/translate/core/checkTranslation.js.map +1 -0
  159. package/lib/translate/core/contribution.d.ts +88 -0
  160. package/lib/translate/core/contribution.d.ts.map +1 -0
  161. package/lib/translate/core/contribution.js +14 -0
  162. package/lib/translate/core/contribution.js.map +1 -0
  163. package/lib/translate/core/index.d.ts +19 -0
  164. package/lib/translate/core/index.d.ts.map +1 -0
  165. package/lib/translate/core/index.js +15 -0
  166. package/lib/translate/core/index.js.map +1 -0
  167. package/lib/translate/core/marker.d.ts +45 -0
  168. package/lib/translate/core/marker.d.ts.map +1 -0
  169. package/lib/translate/core/marker.js +79 -0
  170. package/lib/translate/core/marker.js.map +1 -0
  171. package/lib/translate/core/mergeMessages.d.ts +35 -0
  172. package/lib/translate/core/mergeMessages.d.ts.map +1 -0
  173. package/lib/translate/core/mergeMessages.js +69 -0
  174. package/lib/translate/core/mergeMessages.js.map +1 -0
  175. package/lib/translate/core/session.d.ts +102 -0
  176. package/lib/translate/core/session.d.ts.map +1 -0
  177. package/lib/translate/core/session.js +162 -0
  178. package/lib/translate/core/session.js.map +1 -0
  179. package/lib/translate/core/start.d.ts +70 -0
  180. package/lib/translate/core/start.d.ts.map +1 -0
  181. package/lib/translate/core/start.js +73 -0
  182. package/lib/translate/core/start.js.map +1 -0
  183. package/lib/translate/core/suggestions.d.ts +77 -0
  184. package/lib/translate/core/suggestions.d.ts.map +1 -0
  185. package/lib/translate/core/suggestions.js +143 -0
  186. package/lib/translate/core/suggestions.js.map +1 -0
  187. package/lib/translate/core/tables.d.ts +100 -0
  188. package/lib/translate/core/tables.d.ts.map +1 -0
  189. package/lib/translate/core/tables.js +51 -0
  190. package/lib/translate/core/tables.js.map +1 -0
  191. package/lib/translate.d.ts +2 -0
  192. package/lib/translate.d.ts.map +1 -0
  193. package/lib/translate.js +6 -0
  194. package/lib/translate.js.map +1 -0
  195. package/lib/ui.d.ts +1 -0
  196. package/lib/ui.d.ts.map +1 -1
  197. package/lib/ui.js +1 -0
  198. package/lib/ui.js.map +1 -1
  199. package/lib/xtb/core/xtbRelaxer.d.ts +61 -0
  200. package/lib/xtb/core/xtbRelaxer.d.ts.map +1 -0
  201. package/lib/xtb/core/xtbRelaxer.js +108 -0
  202. package/lib/xtb/core/xtbRelaxer.js.map +1 -0
  203. package/lib/xtb.d.ts +11 -0
  204. package/lib/xtb.d.ts.map +1 -0
  205. package/lib/xtb.js +10 -0
  206. package/lib/xtb.js.map +1 -0
  207. package/package.json +18 -11
  208. package/src/chrome/ui/HeaderToggle.tsx +100 -0
  209. package/src/chrome/ui/index.ts +2 -0
  210. package/src/citation/core/gfn2Paper.ts +32 -0
  211. package/src/citation/core/index.ts +1 -0
  212. package/src/clipboard/ui/ClickToCopy.tsx +8 -7
  213. package/src/conformers.ts +87 -0
  214. package/src/core.ts +1 -0
  215. package/src/credits/core/credits.ts +16 -0
  216. package/src/ecosystem/core/index.ts +1 -0
  217. package/src/ecosystem/core/sites.ts +20 -2
  218. package/src/ecosystem/ui/EcosystemLinks.tsx +3 -1
  219. package/src/ecosystem/ui/SiteTile.tsx +3 -1
  220. package/src/format/core/index.ts +1 -1
  221. package/src/format/core/words.ts +19 -0
  222. package/src/language/core/index.ts +7 -0
  223. package/src/language/core/languageName.ts +16 -0
  224. package/src/language/core/languageParam.ts +70 -0
  225. package/src/language/ui/SiteLanguage.tsx +26 -0
  226. package/src/language/ui/index.ts +3 -0
  227. package/src/language/ui/siteLanguageContext.ts +20 -0
  228. package/src/orbital/core/atomicOrbitals.ts +17 -1
  229. package/src/orbital/core/hydrogenic.ts +2 -1
  230. package/src/orbital/core/index.ts +1 -0
  231. package/src/orbital/core/slaterEnergy.ts +83 -0
  232. package/src/shared/ui/MenuButton.tsx +4 -3
  233. package/src/structure/core/conformerMinimise.ts +99 -0
  234. package/src/structure/core/conformerMinimum.ts +125 -0
  235. package/src/structure/core/conformerOptions.ts +125 -0
  236. package/src/structure/core/conformerRefine.ts +234 -0
  237. package/src/structure/core/conformerRefineRank.ts +89 -0
  238. package/src/structure/core/conformerSession.ts +123 -0
  239. package/src/structure/core/conformerShape.ts +181 -0
  240. package/src/structure/core/conformers.ts +282 -0
  241. package/src/structure/core/geometryRelaxer.ts +71 -0
  242. package/src/structure/core/index.ts +13 -0
  243. package/src/structure/core/moleculeCoordinates.ts +93 -0
  244. package/src/structure/core/molfileExport.ts +62 -0
  245. package/src/structure/core/oclResources.ts +41 -0
  246. package/src/translate/core/catalog.ts +83 -0
  247. package/src/translate/core/checkTranslation.ts +128 -0
  248. package/src/translate/core/contribution.ts +99 -0
  249. package/src/translate/core/index.ts +72 -0
  250. package/src/translate/core/marker.ts +85 -0
  251. package/src/translate/core/mergeMessages.ts +74 -0
  252. package/src/translate/core/session.ts +250 -0
  253. package/src/translate/core/start.ts +121 -0
  254. package/src/translate/core/suggestions.ts +211 -0
  255. package/src/translate/core/tables.ts +120 -0
  256. package/src/translate.ts +5 -0
  257. package/src/ui.ts +1 -0
  258. package/src/xtb/core/xtbRelaxer.ts +160 -0
  259. package/src/xtb.ts +16 -0
  260. package/styles/chrome.css +61 -30
@@ -0,0 +1,74 @@
1
+ import type { Messages } from './catalog.ts';
2
+ import { ownMessage } from './catalog.ts';
3
+
4
+ /**
5
+ * Merge a translator's messages into a locale file.
6
+ *
7
+ * Keys come out in the order of the English file, so a pull request's diff is
8
+ * the lines that changed and nothing else; keys the English file no longer has
9
+ * are kept, last, because removing them is not the translator's decision.
10
+ * @param source - The English messages, whose order is followed.
11
+ * @param existing - The locale file as it is in the repository.
12
+ * @param updates - The messages the translator wrote.
13
+ * @returns The merged messages.
14
+ */
15
+ export function mergeMessages(
16
+ source: Messages,
17
+ existing: Messages,
18
+ updates: Messages,
19
+ ): Messages {
20
+ const entries: Array<[string, string]> = [];
21
+ for (const key of Object.keys(source)) {
22
+ const message = ownMessage(updates, key) ?? ownMessage(existing, key);
23
+ if (message !== undefined) entries.push([key, message]);
24
+ }
25
+ for (const [key, message] of Object.entries(existing)) {
26
+ if (!Object.hasOwn(source, key)) entries.push([key, message]);
27
+ }
28
+ return Object.fromEntries(entries);
29
+ }
30
+
31
+ /**
32
+ * The keys whose message an update actually changes.
33
+ * @param existing - The locale file as it is in the repository.
34
+ * @param updates - The messages the translator wrote.
35
+ * @returns The keys that differ, in the order of the updates.
36
+ */
37
+ export function changedKeys(existing: Messages, updates: Messages): string[] {
38
+ const keys: string[] = [];
39
+ for (const [key, message] of Object.entries(updates)) {
40
+ if (ownMessage(existing, key) !== message) keys.push(key);
41
+ }
42
+ return keys;
43
+ }
44
+
45
+ /**
46
+ * Write messages the way a catalog file is committed: two-space JSON with a
47
+ * final newline, which is also what Prettier leaves untouched.
48
+ * @param messages - The messages.
49
+ * @returns The file content.
50
+ */
51
+ export function serializeMessages(messages: Messages): string {
52
+ return `${JSON.stringify(messages, null, 2)}\n`;
53
+ }
54
+
55
+ /**
56
+ * Read a catalog file, refusing anything but a flat object of strings.
57
+ * @param text - The file content.
58
+ * @returns The messages.
59
+ * @throws {TypeError} When the content is not a flat object of strings.
60
+ */
61
+ export function parseMessages(text: string): Messages {
62
+ const parsed: unknown = JSON.parse(text);
63
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
64
+ throw new TypeError('a catalog file must hold one JSON object');
65
+ }
66
+ const entries: Array<[string, string]> = [];
67
+ for (const [key, value] of Object.entries(parsed)) {
68
+ if (typeof value !== 'string') {
69
+ throw new TypeError(`the message "${key}" is not a string`);
70
+ }
71
+ entries.push([key, value]);
72
+ }
73
+ return Object.fromEntries(entries);
74
+ }
@@ -0,0 +1,250 @@
1
+ import { IntlMessageFormat } from 'intl-messageformat';
2
+
3
+ import type { CatalogSource, MessageRef, Messages } from './catalog.ts';
4
+ import { SOURCE_LOCALE, ownMessage } from './catalog.ts';
5
+ import { checkTranslation } from './checkTranslation.ts';
6
+ import { markText } from './marker.ts';
7
+ import type { TranslatableTable } from './tables.ts';
8
+
9
+ /** The values a message's placeholders are filled with. */
10
+ export type MessageValues = Record<string, string | number | Date>;
11
+
12
+ /** The version of the page ↔ overlay protocol this session speaks. */
13
+ export const BRIDGE_PROTOCOL = 2;
14
+
15
+ /** Where a page in translate mode exposes its session to the overlay. */
16
+ export const BRIDGE_GLOBAL = '__cheminfoTranslate';
17
+
18
+ /** A catalog as the overlay sees it: the source, what is published, the edits. */
19
+ export interface CatalogSnapshot extends CatalogSource {
20
+ /** The locale file as published with the site. */
21
+ translation: Messages;
22
+ /** The messages edited in the overlay and not yet submitted. */
23
+ drafts: Messages;
24
+ }
25
+
26
+ /**
27
+ * What a page in translate mode offers the overlay. It is the only contract
28
+ * between the two, so an overlay released later still drives a page built
29
+ * earlier as long as the protocol number matches.
30
+ */
31
+ export interface TranslateBridge {
32
+ /** The protocol version; the overlay refuses a page speaking another. */
33
+ readonly protocol: typeof BRIDGE_PROTOCOL;
34
+ /** The locale being translated into. */
35
+ readonly locale: string;
36
+ /** Every catalog the page registered. */
37
+ catalogs: () => CatalogSnapshot[];
38
+ /**
39
+ * The tables of the page whose rows a translator may write — the element
40
+ * sheet, a list of ions — with the columns that are text rather than
41
+ * measurement. Empty on a page that shows no table.
42
+ */
43
+ tables: () => TranslatableTable[];
44
+ /** The message a marker found in the DOM stands for. */
45
+ resolveMarker: (id: number) => MessageRef | undefined;
46
+ /** Show an edited message on the page; `undefined` removes the edit. */
47
+ setDraft: (ref: MessageRef, message: string | undefined) => void;
48
+ /** Be told whenever a draft changes; returns the function that stops it. */
49
+ subscribe: (listener: () => void) => () => void;
50
+ }
51
+
52
+ /** How a session is set up. */
53
+ export interface TranslateSessionOptions {
54
+ /** The locale messages are shown in. */
55
+ locale: string;
56
+ /** Every catalog the page renders from. */
57
+ catalogs: readonly CatalogSource[];
58
+ /**
59
+ * The published messages of `locale`, by catalog id.
60
+ * @default {}
61
+ */
62
+ translations?: Readonly<Record<string, Messages>>;
63
+ /**
64
+ * Whether every formatted message carries its invisible marker, which is
65
+ * what translate mode is.
66
+ * @default false
67
+ */
68
+ marked?: boolean;
69
+ /**
70
+ * The tables whose rows are translated, declared so the overlay can offer
71
+ * them as a grid: a cell of a table is rarely on the page being looked at,
72
+ * and 118 of them are never Alt-clicked one by one.
73
+ * @default []
74
+ */
75
+ tables?: readonly TranslatableTable[];
76
+ }
77
+
78
+ interface CatalogState {
79
+ source: CatalogSource;
80
+ translation: Messages;
81
+ drafts: Map<string, string>;
82
+ }
83
+
84
+ /**
85
+ * Formats the messages of a page, and in translate mode lets the overlay edit
86
+ * them live: a draft replaces the published message as soon as it can be
87
+ * formatted, and a draft that cannot is ignored rather than breaking the page.
88
+ */
89
+ export class TranslateSession implements TranslateBridge {
90
+ public readonly protocol = BRIDGE_PROTOCOL;
91
+ public readonly locale: string;
92
+ readonly #marked: boolean;
93
+ readonly #catalogs = new Map<string, CatalogState>();
94
+ readonly #ids = new Map<string, number>();
95
+ readonly #refs: MessageRef[] = [];
96
+ readonly #listeners = new Set<() => void>();
97
+ readonly #formatters = new Map<string, IntlMessageFormat | null>();
98
+ readonly #checked = new Map<string, boolean>();
99
+ readonly #tables: readonly TranslatableTable[];
100
+ #version = 0;
101
+
102
+ public constructor(options: TranslateSessionOptions) {
103
+ const {
104
+ locale,
105
+ catalogs,
106
+ translations = {},
107
+ marked = false,
108
+ tables = [],
109
+ } = options;
110
+ this.locale = locale;
111
+ this.#marked = marked;
112
+ this.#tables = tables;
113
+ for (const source of catalogs) {
114
+ this.#catalogs.set(source.id, {
115
+ source,
116
+ translation: translations[source.id] ?? {},
117
+ drafts: new Map(),
118
+ });
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Increases on every draft change, for a view that re-renders on it.
124
+ * @returns The current version.
125
+ */
126
+ public get version(): number {
127
+ return this.#version;
128
+ }
129
+
130
+ /**
131
+ * Format one message: the draft, else the published translation, else the
132
+ * English message — the first that passes its check against the English
133
+ * one and formats. A draft that drops a placeholder formats without error,
134
+ * so the check, not the formatting, is what keeps it off the page.
135
+ * @param catalogId - The catalog the message belongs to.
136
+ * @param key - The key of the message.
137
+ * @param values - What the placeholders are filled with.
138
+ * @returns The text to show; the key itself when no catalog has it.
139
+ */
140
+ public format(catalogId: string, key: string, values?: MessageValues) {
141
+ const catalog = this.#catalogs.get(catalogId);
142
+ if (catalog === undefined) return key;
143
+ const source = ownMessage(catalog.source.messages, key);
144
+ if (source === undefined) return key;
145
+
146
+ const draft = this.#usable(source, catalog.drafts.get(key));
147
+ const translation = this.#usable(
148
+ source,
149
+ ownMessage(catalog.translation, key),
150
+ );
151
+ const text =
152
+ this.#tryFormat(draft, this.locale, values) ??
153
+ this.#tryFormat(translation, this.locale, values) ??
154
+ this.#tryFormat(source, SOURCE_LOCALE, values) ??
155
+ source;
156
+ return this.#marked ? markText(text, this.#idOf(catalogId, key)) : text;
157
+ }
158
+
159
+ public catalogs(): CatalogSnapshot[] {
160
+ const snapshots: CatalogSnapshot[] = [];
161
+ for (const { source, translation, drafts } of this.#catalogs.values()) {
162
+ snapshots.push({
163
+ ...source,
164
+ translation,
165
+ drafts: Object.fromEntries(drafts),
166
+ });
167
+ }
168
+ return snapshots;
169
+ }
170
+
171
+ /**
172
+ * The tables whose rows a translator may write.
173
+ * @returns One entry per table the page declared.
174
+ */
175
+ public tables(): TranslatableTable[] {
176
+ return [...this.#tables];
177
+ }
178
+
179
+ public resolveMarker(id: number): MessageRef | undefined {
180
+ return this.#refs[id];
181
+ }
182
+
183
+ public setDraft(ref: MessageRef, message: string | undefined): void {
184
+ const catalog = this.#catalogs.get(ref.catalogId);
185
+ if (catalog === undefined) return;
186
+ if (ownMessage(catalog.source.messages, ref.key) === undefined) return;
187
+ if (catalog.drafts.get(ref.key) === message) return;
188
+ if (message === undefined) {
189
+ catalog.drafts.delete(ref.key);
190
+ } else {
191
+ catalog.drafts.set(ref.key, message);
192
+ }
193
+ this.#version++;
194
+ for (const listener of this.#listeners) listener();
195
+ }
196
+
197
+ public subscribe(listener: () => void): () => void {
198
+ this.#listeners.add(listener);
199
+ return () => {
200
+ this.#listeners.delete(listener);
201
+ };
202
+ }
203
+
204
+ #idOf(catalogId: string, key: string): number {
205
+ const name = `${catalogId}\n${key}`;
206
+ let id = this.#ids.get(name);
207
+ if (id === undefined) {
208
+ id = this.#refs.length;
209
+ this.#refs.push({ catalogId, key });
210
+ this.#ids.set(name, id);
211
+ }
212
+ return id;
213
+ }
214
+
215
+ #usable(source: string, message: string | undefined): string | undefined {
216
+ if (message === undefined) return undefined;
217
+ const name = JSON.stringify([source, message]);
218
+ let usable = this.#checked.get(name);
219
+ if (usable === undefined) {
220
+ usable = checkTranslation(source, message).length === 0;
221
+ this.#checked.set(name, usable);
222
+ }
223
+ return usable ? message : undefined;
224
+ }
225
+
226
+ #tryFormat(
227
+ message: string | undefined,
228
+ locale: string,
229
+ values: MessageValues | undefined,
230
+ ): string | undefined {
231
+ if (message === undefined) return undefined;
232
+ const name = `${locale}\n${message}`;
233
+ let formatter = this.#formatters.get(name);
234
+ if (formatter === undefined) {
235
+ try {
236
+ formatter = new IntlMessageFormat(message, locale);
237
+ } catch {
238
+ formatter = null;
239
+ }
240
+ this.#formatters.set(name, formatter);
241
+ }
242
+ if (formatter === null) return undefined;
243
+ try {
244
+ const output = formatter.format(values);
245
+ return Array.isArray(output) ? output.join('') : String(output);
246
+ } catch {
247
+ return undefined;
248
+ }
249
+ }
250
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Turning a page into a translatable one.
3
+ *
4
+ * A site opened with `?translate=<locale>` formats every message with its
5
+ * invisible marker, hands its session to the overlay as
6
+ * `window.__cheminfoTranslate`, and loads that overlay from
7
+ * translate.cheminfo.org. Nothing of this is paid for by an ordinary visit:
8
+ * the site imports this module only once it has seen the parameter.
9
+ */
10
+
11
+ import { isLanguageTag } from '../../language/core/languageParam.ts';
12
+
13
+ import type { CatalogSource, Messages } from './catalog.ts';
14
+ import { SOURCE_LOCALE } from './catalog.ts';
15
+ import { BRIDGE_GLOBAL, TranslateSession } from './session.ts';
16
+ import type { TranslatableTable } from './tables.ts';
17
+
18
+ /** The query parameter that opens a page in translate mode. */
19
+ export const TRANSLATE_PARAM = 'translate';
20
+
21
+ /** Where the overlay is served from unless a site says otherwise. */
22
+ export const TRANSLATE_ORIGIN = 'https://translate.cheminfo.org';
23
+
24
+ /**
25
+ * The locale a page is being translated into, from its address:
26
+ * `?translate=fr` opens the overlay for French.
27
+ * @param search - The query string, e.g. `location.search`.
28
+ * @returns The locale, or `undefined` when the page is not in translate mode,
29
+ * or names English, or names something that is not a language at all.
30
+ */
31
+ export function readTranslateLocale(search: string): string | undefined {
32
+ const value = new URLSearchParams(search).get(TRANSLATE_PARAM);
33
+ if (value === null || value === SOURCE_LOCALE || !isLanguageTag(value)) {
34
+ return undefined;
35
+ }
36
+ return value;
37
+ }
38
+
39
+ /** How a page enters translate mode. */
40
+ export interface StartTranslatingOptions {
41
+ /** The locale being translated into, from {@link readTranslateLocale}. */
42
+ locale: string;
43
+ /** Every catalog the page renders messages from. */
44
+ catalogs: readonly CatalogSource[];
45
+ /**
46
+ * The published messages of the locale, by catalog id, so the overlay opens
47
+ * on what the site already ships rather than on English.
48
+ * @default {}
49
+ */
50
+ translations?: Readonly<Record<string, Messages>>;
51
+ /**
52
+ * The tables whose rows a translator may write.
53
+ * @default []
54
+ */
55
+ tables?: readonly TranslatableTable[];
56
+ /**
57
+ * Where the overlay is loaded from.
58
+ * @default 'https://translate.cheminfo.org'
59
+ */
60
+ origin?: string;
61
+ /**
62
+ * The document the overlay script is added to.
63
+ * @default globalThis.document
64
+ */
65
+ document?: Document;
66
+ }
67
+
68
+ /**
69
+ * Put a page into translate mode.
70
+ *
71
+ * The session is exposed before the overlay is loaded, so the overlay finds a
72
+ * page already formatting marked messages. The site is handed the session back
73
+ * and must format through it from then on — that is what makes an edit show up
74
+ * as it is typed.
75
+ * @param options - See {@link StartTranslatingOptions}.
76
+ * @returns The session the page formats through.
77
+ */
78
+ export function startTranslating(
79
+ options: StartTranslatingOptions,
80
+ ): TranslateSession {
81
+ const {
82
+ locale,
83
+ catalogs,
84
+ translations = {},
85
+ tables = [],
86
+ origin = TRANSLATE_ORIGIN,
87
+ document: target = globalThis.document,
88
+ } = options;
89
+
90
+ const session = new TranslateSession({
91
+ locale,
92
+ catalogs,
93
+ translations,
94
+ tables,
95
+ marked: true,
96
+ });
97
+ Object.assign(globalThis, { [BRIDGE_GLOBAL]: session });
98
+ loadOverlay(origin, target);
99
+ return session;
100
+ }
101
+
102
+ /**
103
+ * Add the overlay script to the page, once.
104
+ * @param origin - Where the overlay is served from.
105
+ * @param target - The document to add it to.
106
+ */
107
+ export function loadOverlay(
108
+ origin: string,
109
+ target = globalThis.document,
110
+ ): void {
111
+ if (target === undefined) return;
112
+ const src = `${origin.replace(/\/$/, '')}/overlay.js`;
113
+ for (const loaded of target.querySelectorAll('script')) {
114
+ if (loaded.src === src) return;
115
+ }
116
+ const script = target.createElement('script');
117
+ script.type = 'module';
118
+ script.src = src;
119
+ script.async = true;
120
+ target.head.append(script);
121
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * What a translator is offered before typing: the translations already made
3
+ * for the same English, for English that differs only where the text is not
4
+ * language, and for English that merely looks like it.
5
+ *
6
+ * A table is where this earns its place. Half of 118 element names are the
7
+ * same word in two languages and the other half differ by an ending; a column
8
+ * of quantities is a handful of phrases repeated with one word changed. A
9
+ * translator who is shown what was done three rows above confirms instead of
10
+ * typing, and the column stays consistent with itself — which is the thing a
11
+ * long table loses first.
12
+ *
13
+ * Nothing here invents a translation. Every suggestion is a translation
14
+ * somebody already wrote, either as it stands or with the part that is not
15
+ * language put back.
16
+ */
17
+
18
+ import leven from 'leven';
19
+
20
+ /** One message already translated, which a suggestion is drawn from. */
21
+ export interface TranslatedPair {
22
+ /** The key it is kept under, so the translator can see where it came from. */
23
+ key: string;
24
+ /** The English message. */
25
+ source: string;
26
+ /** Its translation. */
27
+ target: string;
28
+ }
29
+
30
+ /**
31
+ * How a suggestion was arrived at.
32
+ *
33
+ * - `exact` — the same English message, translated elsewhere.
34
+ * - `pattern` — English that differs only in a part that is not language (a
35
+ * number, a symbol, a formula), with that part put back into the
36
+ * translation.
37
+ * - `similar` — English that looks like this one; the translation is offered
38
+ * to be adapted, not to be taken as it is.
39
+ */
40
+ export type SuggestionKind = 'exact' | 'pattern' | 'similar';
41
+
42
+ /** One translation offered for a message. */
43
+ export interface MessageSuggestion {
44
+ /** What goes in the box if the translator takes it. */
45
+ message: string;
46
+ /** How it was arrived at. */
47
+ kind: SuggestionKind;
48
+ /** Between 0 and 1; 1 is the same English message. */
49
+ score: number;
50
+ /** The already-translated message it was drawn from. */
51
+ from: TranslatedPair;
52
+ }
53
+
54
+ /** How suggestions are chosen. */
55
+ export interface SuggestionOptions {
56
+ /**
57
+ * The most suggestions to return.
58
+ * @default 5
59
+ */
60
+ limit?: number;
61
+ /**
62
+ * How alike two English messages must be before one is offered for the
63
+ * other, between 0 and 1.
64
+ * @default 0.55
65
+ */
66
+ minimumScore?: number;
67
+ }
68
+
69
+ const DEFAULT_LIMIT = 5;
70
+ const DEFAULT_MINIMUM = 0.55;
71
+ const KIND_ORDER: Record<SuggestionKind, number> = {
72
+ exact: 0,
73
+ pattern: 1,
74
+ similar: 2,
75
+ };
76
+
77
+ /**
78
+ * The translations worth offering for one English message.
79
+ * @param source - The English message being translated.
80
+ * @param pairs - Every message already translated in this language.
81
+ * @param options - See {@link SuggestionOptions}.
82
+ * @returns The suggestions, best first; empty when nothing is close enough.
83
+ */
84
+ export function messageSuggestions(
85
+ source: string,
86
+ pairs: readonly TranslatedPair[],
87
+ options: SuggestionOptions = {},
88
+ ): MessageSuggestion[] {
89
+ const { limit = DEFAULT_LIMIT, minimumScore = DEFAULT_MINIMUM } = options;
90
+ if (source === '' || limit <= 0) return [];
91
+
92
+ const found: MessageSuggestion[] = [];
93
+ for (const pair of pairs) {
94
+ if (pair.target === '' || pair.source === '') continue;
95
+ const suggestion = suggestFrom(source, pair, minimumScore);
96
+ if (suggestion !== undefined) found.push(suggestion);
97
+ }
98
+
99
+ found.sort(compareSuggestions);
100
+ const best: MessageSuggestion[] = [];
101
+ const taken = new Set<string>();
102
+ for (const suggestion of found) {
103
+ if (taken.has(suggestion.message)) continue;
104
+ taken.add(suggestion.message);
105
+ best.push(suggestion);
106
+ if (best.length === limit) break;
107
+ }
108
+ return best;
109
+ }
110
+
111
+ /**
112
+ * How alike two messages are, as the suggestions rank them.
113
+ * @param first - One message.
114
+ * @param second - The other.
115
+ * @returns 1 when they are the same, 0 when they share nothing.
116
+ */
117
+ export function messageSimilarity(first: string, second: string): number {
118
+ const longest = Math.max(first.length, second.length);
119
+ if (longest === 0) return 1;
120
+ return 1 - leven(first, second) / longest;
121
+ }
122
+
123
+ /**
124
+ * The best offer one already-translated message can make for another.
125
+ * @param source - The English being translated.
126
+ * @param pair - The already-translated message to draw from.
127
+ * @param minimumScore - How alike the two must be to be worth offering.
128
+ * @returns The suggestion, or `undefined` when the pair is too far away.
129
+ */
130
+ function suggestFrom(
131
+ source: string,
132
+ pair: TranslatedPair,
133
+ minimumScore: number,
134
+ ): MessageSuggestion | undefined {
135
+ if (pair.source === source) {
136
+ return { message: pair.target, kind: 'exact', score: 1, from: pair };
137
+ }
138
+
139
+ // An edit can never be smaller than the difference in length, so a pair that
140
+ // cannot reach the threshold is dropped before the distance is computed.
141
+ const longest = Math.max(source.length, pair.source.length);
142
+ const shortest = Math.min(source.length, pair.source.length);
143
+ if (longest === 0 || shortest / longest < minimumScore) return undefined;
144
+
145
+ const pattern = patternSuggestion(source, pair);
146
+ if (pattern !== undefined && pattern.score >= minimumScore) return pattern;
147
+
148
+ const score = messageSimilarity(source, pair.source);
149
+ if (score < minimumScore) return undefined;
150
+ return { message: pair.target, kind: 'similar', score, from: pair };
151
+ }
152
+
153
+ /**
154
+ * A translation whose English differs from this one only in a run of text
155
+ * that survives into the translation unchanged — a number, a symbol, a
156
+ * formula. That run is the only thing put back, so nothing is invented.
157
+ * @param source - The English being translated.
158
+ * @param pair - The already-translated message to draw from.
159
+ * @returns The suggestion, or `undefined` when the run cannot be put back.
160
+ */
161
+ function patternSuggestion(
162
+ source: string,
163
+ pair: TranslatedPair,
164
+ ): MessageSuggestion | undefined {
165
+ const prefix = commonPrefix(source, pair.source);
166
+ const suffix = commonSuffix(source.slice(prefix), pair.source.slice(prefix));
167
+ const own = source.slice(prefix, source.length - suffix);
168
+ const theirs = pair.source.slice(prefix, pair.source.length - suffix);
169
+ if (theirs === '') return undefined;
170
+
171
+ // The varying run must appear once and only once, or putting it back would
172
+ // be a guess about which occurrence was meant.
173
+ const at = pair.target.indexOf(theirs);
174
+ if (at === -1 || pair.target.includes(theirs, at + 1)) return undefined;
175
+
176
+ const message =
177
+ pair.target.slice(0, at) + own + pair.target.slice(at + theirs.length);
178
+ const kept = prefix + suffix;
179
+ const score = kept / Math.max(source.length, pair.source.length);
180
+ return { message, kind: 'pattern', score, from: pair };
181
+ }
182
+
183
+ function commonPrefix(first: string, second: string): number {
184
+ const limit = Math.min(first.length, second.length);
185
+ let length = 0;
186
+ while (length < limit && first[length] === second[length]) length++;
187
+ return length;
188
+ }
189
+
190
+ function commonSuffix(first: string, second: string): number {
191
+ const limit = Math.min(first.length, second.length);
192
+ let length = 0;
193
+ while (
194
+ length < limit &&
195
+ first[first.length - 1 - length] === second[second.length - 1 - length]
196
+ ) {
197
+ length++;
198
+ }
199
+ return length;
200
+ }
201
+
202
+ function compareSuggestions(
203
+ first: MessageSuggestion,
204
+ second: MessageSuggestion,
205
+ ): number {
206
+ if (first.kind !== second.kind) {
207
+ return KIND_ORDER[first.kind] - KIND_ORDER[second.kind];
208
+ }
209
+ if (first.score !== second.score) return second.score - first.score;
210
+ return first.from.key < second.from.key ? -1 : 1;
211
+ }