react-cheminfo 0.53.0 → 0.55.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 (85) hide show
  1. package/README.md +14 -0
  2. package/lib/chrome/ui/SiteHeader.d.ts +17 -1
  3. package/lib/chrome/ui/SiteHeader.d.ts.map +1 -1
  4. package/lib/chrome/ui/SiteHeader.js +9 -2
  5. package/lib/chrome/ui/SiteHeader.js.map +1 -1
  6. package/lib/contact/core/contactUrl.d.ts +35 -0
  7. package/lib/contact/core/contactUrl.d.ts.map +1 -0
  8. package/lib/contact/core/contactUrl.js +33 -0
  9. package/lib/contact/core/contactUrl.js.map +1 -0
  10. package/lib/contact/core/index.d.ts +3 -0
  11. package/lib/contact/core/index.d.ts.map +1 -0
  12. package/lib/contact/core/index.js +2 -0
  13. package/lib/contact/core/index.js.map +1 -0
  14. package/lib/contact/ui/ContactButton.d.ts +41 -0
  15. package/lib/contact/ui/ContactButton.d.ts.map +1 -0
  16. package/lib/contact/ui/ContactButton.js +29 -0
  17. package/lib/contact/ui/ContactButton.js.map +1 -0
  18. package/lib/contact/ui/index.d.ts +3 -0
  19. package/lib/contact/ui/index.d.ts.map +1 -0
  20. package/lib/contact/ui/index.js +2 -0
  21. package/lib/contact/ui/index.js.map +1 -0
  22. package/lib/core.d.ts +1 -0
  23. package/lib/core.d.ts.map +1 -1
  24. package/lib/core.js +1 -0
  25. package/lib/core.js.map +1 -1
  26. package/lib/i18n/core/chromeCatalog.d.ts +1 -1
  27. package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
  28. package/lib/i18n/core/index.d.ts +2 -0
  29. package/lib/i18n/core/index.d.ts.map +1 -1
  30. package/lib/i18n/core/index.js +1 -0
  31. package/lib/i18n/core/index.js.map +1 -1
  32. package/lib/i18n/core/messageCatalog.d.ts +10 -0
  33. package/lib/i18n/core/messageCatalog.d.ts.map +1 -1
  34. package/lib/i18n/core/messageCatalog.js +12 -0
  35. package/lib/i18n/core/messageCatalog.js.map +1 -1
  36. package/lib/i18n/core/translate.d.ts +35 -0
  37. package/lib/i18n/core/translate.d.ts.map +1 -0
  38. package/lib/i18n/core/translate.js +27 -0
  39. package/lib/i18n/core/translate.js.map +1 -0
  40. package/lib/i18n/ui/useT.d.ts +2 -12
  41. package/lib/i18n/ui/useT.d.ts.map +1 -1
  42. package/lib/i18n/ui/useT.js +2 -8
  43. package/lib/i18n/ui/useT.js.map +1 -1
  44. package/lib/i18n/vite/index.d.ts +3 -0
  45. package/lib/i18n/vite/index.d.ts.map +1 -0
  46. package/lib/i18n/vite/index.js +2 -0
  47. package/lib/i18n/vite/index.js.map +1 -0
  48. package/lib/i18n/vite/localeCatalog.d.ts +70 -0
  49. package/lib/i18n/vite/localeCatalog.d.ts.map +1 -0
  50. package/lib/i18n/vite/localeCatalog.js +80 -0
  51. package/lib/i18n/vite/localeCatalog.js.map +1 -0
  52. package/lib/locales/de.json +2 -0
  53. package/lib/locales/en.json +2 -0
  54. package/lib/locales/es.json +2 -0
  55. package/lib/locales/fr.json +2 -0
  56. package/lib/locales/it.json +2 -0
  57. package/lib/ui.d.ts +1 -0
  58. package/lib/ui.d.ts.map +1 -1
  59. package/lib/ui.js +1 -0
  60. package/lib/ui.js.map +1 -1
  61. package/lib/vite.d.ts +1 -0
  62. package/lib/vite.d.ts.map +1 -1
  63. package/lib/vite.js +1 -0
  64. package/lib/vite.js.map +1 -1
  65. package/package.json +1 -1
  66. package/src/chrome/ui/SiteHeader.tsx +35 -3
  67. package/src/contact/core/contactUrl.ts +53 -0
  68. package/src/contact/core/index.ts +2 -0
  69. package/src/contact/ui/ContactButton.tsx +74 -0
  70. package/src/contact/ui/index.ts +2 -0
  71. package/src/core.ts +1 -0
  72. package/src/i18n/core/index.ts +2 -0
  73. package/src/i18n/core/messageCatalog.ts +13 -0
  74. package/src/i18n/core/translate.ts +51 -0
  75. package/src/i18n/ui/useT.ts +6 -29
  76. package/src/i18n/vite/index.ts +5 -0
  77. package/src/i18n/vite/localeCatalog.ts +121 -0
  78. package/src/locales/de.json +2 -0
  79. package/src/locales/en.json +2 -0
  80. package/src/locales/es.json +2 -0
  81. package/src/locales/fr.json +2 -0
  82. package/src/locales/it.json +2 -0
  83. package/src/ui.ts +1 -0
  84. package/src/vite.ts +1 -0
  85. package/styles/chrome.css +21 -0
@@ -0,0 +1,74 @@
1
+ import type { ReactElement } from 'react';
2
+
3
+ import { NavLink } from '../../chrome/ui/NavLink.tsx';
4
+ import { useLanguage } from '../../i18n/ui/useLanguage.ts';
5
+ import { useChromeT } from '../../i18n/ui/useT.ts';
6
+ import { contactFormUrl, contactUrl } from '../core/contactUrl.ts';
7
+
8
+ /** What the Contact entry of a header needs. */
9
+ export interface ContactButtonProps {
10
+ /** The site the reader writes about, written into the form. */
11
+ siteId: string;
12
+ /**
13
+ * Address of the form, with `{site}` and `{page}` where they are written.
14
+ * @default the family's form in the language of the page (`CONTACT_FORM_URLS`)
15
+ */
16
+ formUrl?: string;
17
+ /**
18
+ * The page the reader is on, written into the form.
19
+ * @default the address of the current page
20
+ */
21
+ page?: string;
22
+ /**
23
+ * What the entry reads. In a compact bar it is not written, but it stays what
24
+ * the pointer and a screen reader are told.
25
+ * @default the chrome's own word for it, in the language of the page
26
+ */
27
+ label?: string;
28
+ /**
29
+ * What the pointer is told.
30
+ * @default the chrome's own sentence, in the language of the page
31
+ */
32
+ title?: string;
33
+ /**
34
+ * Class names added after the component's own.
35
+ * @default undefined
36
+ */
37
+ className?: string;
38
+ }
39
+
40
+ /**
41
+ * The Contact entry of a site header: it opens the family's contact form in a new tab,
42
+ * with the site and the page already filled in, so the reader only writes the message.
43
+ * The form is opened, never embedded, so nothing is loaded from it before the click.
44
+ * @param props - The site, and the form it opens.
45
+ * @returns The entry, or nothing while no form address is set.
46
+ */
47
+ export function ContactButton(props: ContactButtonProps): ReactElement | null {
48
+ const {
49
+ siteId,
50
+ page = globalThis.location?.href ?? '',
51
+ label,
52
+ title,
53
+ className,
54
+ } = props;
55
+ const t = useChromeT();
56
+ const language = useLanguage();
57
+ const formUrl = props.formUrl ?? contactFormUrl(language);
58
+
59
+ if (formUrl === '') return null;
60
+
61
+ return (
62
+ <NavLink
63
+ item={{
64
+ id: 'contact',
65
+ label: label ?? t('chrome.contact'),
66
+ icon: 'chat',
67
+ href: contactUrl(formUrl, { site: siteId, page }),
68
+ external: true,
69
+ title: title ?? t('chrome.contactTitle'),
70
+ }}
71
+ className={className}
72
+ />
73
+ );
74
+ }
@@ -0,0 +1,2 @@
1
+ export type { ContactButtonProps } from './ContactButton.tsx';
2
+ export { ContactButton } from './ContactButton.tsx';
package/src/core.ts CHANGED
@@ -5,6 +5,7 @@ export * from './chart/core/index.ts';
5
5
  export * from './citation/core/index.ts';
6
6
  export * from './clipboard/core/index.ts';
7
7
  export * from './color/core/index.ts';
8
+ export * from './contact/core/index.ts';
8
9
  export * from './credits/core/index.ts';
9
10
  export * from './delimited/core/index.ts';
10
11
  export * from './download/core/index.ts';
@@ -15,6 +15,8 @@ export type {
15
15
  } from './messageCatalog.ts';
16
16
  export { MessageCatalog } from './messageCatalog.ts';
17
17
  export type { MessageValues } from './messageValues.ts';
18
+ export type { Translate } from './translate.ts';
19
+ export { catalogTranslate } from './translate.ts';
18
20
  export type { ProsePiece } from './prose.ts';
19
21
  export { splitProse } from './prose.ts';
20
22
  export type { MessageSession } from './session.ts';
@@ -141,6 +141,19 @@ export class MessageCatalog<TKey extends string = string> {
141
141
  return fill(message, values);
142
142
  }
143
143
 
144
+ /**
145
+ * Whether the catalog declares a key at all.
146
+ *
147
+ * English is the list of keys, so this is what a loop walking a numbered
148
+ * family of messages — `how.limits.p1`, `p2`, … — stops on, and what tells a
149
+ * page a section was never written from one left untranslated.
150
+ * @param key - Key to look for.
151
+ * @returns True when English carries it.
152
+ */
153
+ public declares(key: string): boolean {
154
+ return ownMessage(this.source.messages, key) !== undefined;
155
+ }
156
+
144
157
  /**
145
158
  * The messages of a language, once they are in memory.
146
159
  * @param language - Language asked for.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The formatter a catalog's text is read through, outside React.
3
+ *
4
+ * `useT` is this function with the page's language and a redraw wired to it;
5
+ * the build and a site's own tests need the same shape without either, so it
6
+ * lives here rather than in the hook. A site never writes its own: four of
7
+ * them had, each with a slightly different fallback.
8
+ */
9
+
10
+ import type { Language } from './languages.ts';
11
+ import type { LooseKey, MessageCatalog } from './messageCatalog.ts';
12
+ import type { MessageValues } from './messageValues.ts';
13
+
14
+ /**
15
+ * What text is read with.
16
+ *
17
+ * `t(key)` is a key the catalog declares, checked at compile time.
18
+ * `t.or(key, fallback)` is a key built from the data — a site's id, an
19
+ * element's symbol — which the catalog may not have caught up with yet; it
20
+ * reads as the fallback rather than as a raw key.
21
+ */
22
+ export type Translate<TKey extends string> = ((
23
+ key: LooseKey<TKey>,
24
+ values?: MessageValues,
25
+ ) => string) & {
26
+ or: (key: string, fallback: string, values?: MessageValues) => string;
27
+ };
28
+
29
+ /**
30
+ * A catalog's formatter, in one language.
31
+ *
32
+ * In a component this is `useT`, which also redraws when the language or a
33
+ * translator's edit changes it. Here it is for the places a hook cannot go:
34
+ * the build writing a head per address per language, a test asserting on the
35
+ * text a page shows, a label computed in a module helper.
36
+ * @param catalog - The catalog to read, typed by its English keys.
37
+ * @param language - The language to read it in.
38
+ * @returns See {@link Translate}.
39
+ */
40
+ export function catalogTranslate<TKey extends string>(
41
+ catalog: MessageCatalog<TKey>,
42
+ language: Language,
43
+ ): Translate<TKey> {
44
+ // `or` is a property of the function itself, so the two are built together
45
+ // rather than assigned onto a value a render may already have read.
46
+ const translate = (key: LooseKey<TKey>, values?: MessageValues) =>
47
+ catalog.translate(key, language, { values });
48
+ translate.or = (key: string, fallback: string, values?: MessageValues) =>
49
+ catalog.translate(key, language, { fallback, values });
50
+ return translate;
51
+ }
@@ -3,27 +3,17 @@ import { CHROME_CATALOG } from '../core/chromeCatalog.ts';
3
3
  import type {
4
4
  Language,
5
5
  LooseKey,
6
- MessageValues,
6
+ Translate,
7
7
  TranslateOptions,
8
8
  } from '../core/index.ts';
9
+ import { catalogTranslate } from '../core/index.ts';
9
10
  import type { MessageCatalog } from '../core/messageCatalog.ts';
10
11
 
11
12
  import { useLanguage, useMessagesVersion } from './useLanguage.ts';
12
13
 
13
- /**
14
- * What a component reads its text with.
15
- *
16
- * `t(key)` is a key the catalog declares, checked at compile time.
17
- * `t.or(key, fallback)` is a key built from the data — a site's id, an
18
- * element's symbol — which the catalog may not have caught up with yet; it
19
- * reads as the fallback rather than as a raw key.
20
- */
21
- export type Translate<TKey extends string> = ((
22
- key: LooseKey<TKey>,
23
- values?: MessageValues,
24
- ) => string) & {
25
- or: (key: string, fallback: string, values?: MessageValues) => string;
26
- };
14
+ // The formatter's own shape belongs to the catalog, not to the hook, and a site
15
+ // reads it from either entry point.
16
+ export type { Translate } from '../core/index.ts';
27
17
 
28
18
  /**
29
19
  * The formatter of a catalog's text, in the language the page is written in.
@@ -40,20 +30,7 @@ export function useT<TKey extends string>(
40
30
  const language = useLanguage();
41
31
  useMessagesVersion();
42
32
  catalog.prefetch(language);
43
- return translator(catalog, language);
44
- }
45
-
46
- // Built outside the hook, because the `or` form is a property of the function
47
- // itself and a render may not write to a value it has just read.
48
- function translator<TKey extends string>(
49
- catalog: MessageCatalog<TKey>,
50
- language: Language,
51
- ): Translate<TKey> {
52
- const translate = (key: LooseKey<TKey>, values?: MessageValues) =>
53
- catalog.translate(key, language, { values });
54
- translate.or = (key: string, fallback: string, values?: MessageValues) =>
55
- catalog.translate(key, language, { fallback, values });
56
- return translate;
33
+ return catalogTranslate(catalog, language);
57
34
  }
58
35
 
59
36
  /**
@@ -0,0 +1,5 @@
1
+ export type {
2
+ LocaleCatalogOptions,
3
+ LocaleCatalogSource,
4
+ } from './localeCatalog.ts';
5
+ export { readLocaleCatalog } from './localeCatalog.ts';
@@ -0,0 +1,121 @@
1
+ /**
2
+ * A site's catalogs as the build reads them: all of them, off disk, at once.
3
+ *
4
+ * The page and the build want opposite things from the same files. A browser
5
+ * fetches one language — the one being read — so `en.json` is part of the
6
+ * bundle and the others are `import()`ed when a visitor asks, which is what
7
+ * keeps a site nobody reads in German from downloading the German. The build
8
+ * writes a head per address per language in one pass, so it wants every
9
+ * language, synchronously, before the first route table is asked for.
10
+ *
11
+ * Reading them rather than fetching them is what makes that possible. The
12
+ * page's loaders are written bare — `import('../locales/fr.json')` with no
13
+ * `with { type: 'json' }` — because the dev server serves a JSON file as a
14
+ * JavaScript module and a browser refuses a chunk it was promised JSON for.
15
+ * Node demands the attribute, so it cannot run those loaders: a build that
16
+ * awaited them had to be async and still only worked by keeping a second,
17
+ * eagerly imported copy of every catalog beside the first. `readFileSync` has
18
+ * no attribute, so there is nothing to work around.
19
+ *
20
+ * The identity comes from the page's own catalog, never re-typed here: pass
21
+ * `SITE_CATALOG.source` and the two provably name the same files.
22
+ */
23
+
24
+ import { readFileSync } from 'node:fs';
25
+ import { join, resolve } from 'node:path';
26
+
27
+ import type { Language } from '../core/index.ts';
28
+ import { DEFAULT_LANGUAGE, LANGUAGES, MessageCatalog } from '../core/index.ts';
29
+
30
+ /**
31
+ * Which catalog to read, which is the page catalog's own `source`.
32
+ *
33
+ * `MessageCatalog` publishes it for the translate overlay, and it already
34
+ * carries the three things a reader needs. Hand it over rather than writing
35
+ * them twice: a build that names its own directory is a build that can come to
36
+ * disagree with the page about which files the site is translated in.
37
+ */
38
+ export interface LocaleCatalogSource {
39
+ /**
40
+ * The name the page knows the catalog by, which is the site's host, e.g.
41
+ * `surge.cheminfo.org`. A translator's edit is routed back to the right
42
+ * repository with it, so the build and the page must agree on it.
43
+ */
44
+ id: string;
45
+ /** The GitHub repository the files live in, written `owner/repo`. */
46
+ repository: string;
47
+ /** The directory holding the locale files, relative to the repository root. */
48
+ directory: string;
49
+ }
50
+
51
+ /** Where the directory is looked for. */
52
+ export interface LocaleCatalogOptions {
53
+ /**
54
+ * What the source's relative `directory` is resolved against. The default is
55
+ * the working directory, which is the repository root for both callers: a
56
+ * Vite config and a vitest run.
57
+ * @default process.cwd()
58
+ */
59
+ root?: string;
60
+ }
61
+
62
+ /**
63
+ * Every language a site is written in, read off disk into one catalog.
64
+ *
65
+ * For the build and for a site's own tests, never for the page: it reads the
66
+ * filesystem, and the translations it holds are tens of kilobytes a first
67
+ * visit must not pay for. Read one language out of it with `catalogTranslate`.
68
+ *
69
+ * A language whose file is absent is left out, which the catalog already reads
70
+ * as English — a half-translated site serves the pages it has rather than
71
+ * failing the build. A missing English file is a misconfiguration and throws:
72
+ * every key and every fallback is declared there.
73
+ * @param source - The page catalog's own `source`. See {@link LocaleCatalogSource}.
74
+ * @param options - See {@link LocaleCatalogOptions}.
75
+ * @returns The catalog, every language it carries already in memory.
76
+ */
77
+ export function readLocaleCatalog<TKey extends string = string>(
78
+ source: LocaleCatalogSource,
79
+ options: LocaleCatalogOptions = {},
80
+ ): MessageCatalog<TKey> {
81
+ const { id, repository, directory } = source;
82
+ const folder = resolve(options.root ?? process.cwd(), directory);
83
+ const english = readMessages(folder, DEFAULT_LANGUAGE);
84
+ if (english === undefined) {
85
+ throw new Error(
86
+ `no ${DEFAULT_LANGUAGE}.json in ${folder}: a catalog declares its keys and its fallbacks there`,
87
+ );
88
+ }
89
+ const translations: Partial<Record<Language, Record<string, string>>> = {};
90
+ for (const language of LANGUAGES) {
91
+ if (language === DEFAULT_LANGUAGE) continue;
92
+ const messages = readMessages(folder, language);
93
+ if (messages !== undefined) translations[language] = messages;
94
+ }
95
+ return new MessageCatalog<TKey>({
96
+ id,
97
+ repository,
98
+ directory,
99
+ source: english,
100
+ translations,
101
+ });
102
+ }
103
+
104
+ /**
105
+ * One language's messages, read off disk.
106
+ * @param folder - The resolved directory holding the locale files.
107
+ * @param language - The language whose file to read.
108
+ * @returns Its messages, or `undefined` when the site does not carry it.
109
+ */
110
+ function readMessages(
111
+ folder: string,
112
+ language: Language,
113
+ ): Record<string, string> | undefined {
114
+ let text: string;
115
+ try {
116
+ text = readFileSync(join(folder, `${language}.json`), 'utf8');
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ return JSON.parse(text) as Record<string, string>;
121
+ }
@@ -3,6 +3,8 @@
3
3
  "chrome.pages": "Seiten",
4
4
  "chrome.about": "Über",
5
5
  "chrome.aboutTitle": "Was diese Seite ist, worauf sie aufbaut und wie man sie zitiert",
6
+ "chrome.contact": "Kontakt",
7
+ "chrome.contactTitle": "Eine Frage, ein Problem, eine Idee? Schreiben Sie uns",
6
8
  "account.signIn": "Anmelden",
7
9
  "account.signOut": "Abmelden",
8
10
  "account.signedInAs": "Angemeldet als {name}",
@@ -3,6 +3,8 @@
3
3
  "chrome.pages": "Pages",
4
4
  "chrome.about": "About",
5
5
  "chrome.aboutTitle": "What this site is, what it is built on, and how to cite it",
6
+ "chrome.contact": "Contact",
7
+ "chrome.contactTitle": "A question, a problem or an idea? Write to us",
6
8
  "account.signIn": "Sign in",
7
9
  "account.signOut": "Sign out",
8
10
  "account.signedInAs": "Signed in as {name}",
@@ -3,6 +3,8 @@
3
3
  "chrome.pages": "Páginas",
4
4
  "chrome.about": "Acerca de",
5
5
  "chrome.aboutTitle": "Qué es este sitio, en qué se basa y cómo citarlo",
6
+ "chrome.contact": "Contacto",
7
+ "chrome.contactTitle": "¿Una pregunta, un problema, una idea? Escríbanos",
6
8
  "account.signIn": "Iniciar sesión",
7
9
  "account.signOut": "Cerrar sesión",
8
10
  "account.signedInAs": "Sesión iniciada como {name}",
@@ -3,6 +3,8 @@
3
3
  "chrome.pages": "Pages",
4
4
  "chrome.about": "À propos",
5
5
  "chrome.aboutTitle": "Ce qu'est ce site, sur quoi il est bâti et comment le citer",
6
+ "chrome.contact": "Contact",
7
+ "chrome.contactTitle": "Une question, un problème, une idée ? Écrivez-nous",
6
8
  "account.signIn": "Se connecter",
7
9
  "account.signOut": "Se déconnecter",
8
10
  "account.signedInAs": "Connecté comme {name}",
@@ -3,6 +3,8 @@
3
3
  "chrome.pages": "Pagine",
4
4
  "chrome.about": "Informazioni",
5
5
  "chrome.aboutTitle": "Cosa è questo sito, su cosa si basa e come citarlo",
6
+ "chrome.contact": "Contatto",
7
+ "chrome.contactTitle": "Una domanda, un problema, un'idea? Scriveteci",
6
8
  "account.signIn": "Accedi",
7
9
  "account.signOut": "Esci",
8
10
  "account.signedInAs": "Accesso effettuato come {name}",
package/src/ui.ts CHANGED
@@ -4,6 +4,7 @@ export * from './capsule/ui/index.ts';
4
4
  export * from './chart/ui/index.ts';
5
5
  export * from './chrome/ui/index.ts';
6
6
  export * from './citation/ui/index.ts';
7
+ export * from './contact/ui/index.ts';
7
8
  export * from './clipboard/ui/index.ts';
8
9
  export * from './color/ui/index.ts';
9
10
  export * from './confirm/ui/index.ts';
package/src/vite.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './build/vite/index.ts';
2
+ export * from './i18n/vite/index.ts';
2
3
  export * from './seo/vite/index.ts';
3
4
  export * from './slides/vite/index.ts';
package/styles/chrome.css CHANGED
@@ -192,6 +192,27 @@ button {
192
192
  padding: 0.3rem 0.5rem;
193
193
  }
194
194
 
195
+ /* Every utility of the bar takes the same box, whatever it is built on — a link,
196
+ or a Blueprint button opening a menu — so the marks stand evenly spaced: the
197
+ same padding, no minimum width, no room kept beside the glyph for the hidden
198
+ label, and one glyph size. */
199
+ .app-header-actions .bp6-button:has(> .bp6-icon:not(.bp6-icon-caret-down)) {
200
+ min-width: 0;
201
+ padding: 0.3rem 0.5rem;
202
+ }
203
+
204
+ .app-header-actions
205
+ .bp6-button:has(> .bp6-icon:not(.bp6-icon-caret-down))
206
+ > .bp6-icon {
207
+ margin: 0;
208
+ }
209
+
210
+ .app-header-actions .nav-link--icon > .bp6-icon > svg,
211
+ .app-header-actions .bp6-button > .bp6-icon > svg {
212
+ width: 16px;
213
+ height: 16px;
214
+ }
215
+
195
216
  /* A setting the bar shows as its own values — the language, the scale a
196
217
  temperature is read on — the one in force pressed. The values carry no
197
218
  glyph, which is what keeps them written where a utility is only its mark. */