waybetter 0.6.0 → 0.7.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 (190) hide show
  1. package/README.md +1 -2
  2. package/bin/waybetter.mjs +2 -5
  3. package/cli/buildenv.mjs +1 -1
  4. package/cli/release.mjs +11 -18
  5. package/cli/releasecheck.mjs +15 -0
  6. package/cli/remote.mjs +4 -4
  7. package/cli/store.mjs +80 -28
  8. package/cli/strings.mjs +2 -0
  9. package/dist/runtime/stringPackHook.js +2 -2
  10. package/dist/runtime/stringPackHook.js.map +1 -1
  11. package/dist/runtime/stringPackTranslate.d.ts +3 -1
  12. package/dist/runtime/stringPackTranslate.js +6 -3
  13. package/dist/runtime/stringPackTranslate.js.map +1 -1
  14. package/dist/runtime/stringPacks.d.ts +53 -5
  15. package/dist/runtime/stringPacks.js +161 -25
  16. package/dist/runtime/stringPacks.js.map +1 -1
  17. package/dist/runtime/themeBar.d.ts +28 -0
  18. package/dist/runtime/themeBar.js +82 -0
  19. package/dist/runtime/themeBar.js.map +1 -0
  20. package/dist/runtime/themeComponents.js +16 -3
  21. package/dist/runtime/themeComponents.js.map +1 -1
  22. package/dist/runtime/themeDecor.js +3 -1
  23. package/dist/runtime/themeDecor.js.map +1 -1
  24. package/dist/runtime/themeDerive.d.ts +69 -0
  25. package/dist/runtime/themeDerive.js +117 -0
  26. package/dist/runtime/themeDerive.js.map +1 -0
  27. package/dist/runtime/themeDoctor.d.ts +44 -3
  28. package/dist/runtime/themeDoctor.js +86 -25
  29. package/dist/runtime/themeDoctor.js.map +1 -1
  30. package/dist/runtime/themeGuards.d.ts +60 -0
  31. package/dist/runtime/themeGuards.js +235 -0
  32. package/dist/runtime/themeGuards.js.map +1 -0
  33. package/dist/runtime/themeIcons.d.ts +1 -1
  34. package/dist/runtime/themeIcons.js +13 -5
  35. package/dist/runtime/themeIcons.js.map +1 -1
  36. package/dist/runtime/themeInk.d.ts +37 -0
  37. package/dist/runtime/themeInk.js +111 -0
  38. package/dist/runtime/themeInk.js.map +1 -0
  39. package/dist/runtime/themeInvariants.d.ts +39 -0
  40. package/dist/runtime/themeInvariants.js +273 -0
  41. package/dist/runtime/themeInvariants.js.map +1 -0
  42. package/dist/runtime/themeRuntime.d.ts +15 -1
  43. package/dist/runtime/themeRuntime.js +78 -5
  44. package/dist/runtime/themeRuntime.js.map +1 -1
  45. package/dist/runtime/themeSpec.d.ts +5 -1
  46. package/dist/runtime/themeSpec.js +9 -13
  47. package/dist/runtime/themeSpec.js.map +1 -1
  48. package/dist/shell/InstallDialog.js +14 -8
  49. package/dist/shell/InstallDialog.js.map +1 -1
  50. package/dist/shell/StoreView.d.ts +12 -0
  51. package/dist/shell/StoreView.js +9 -4
  52. package/dist/shell/StoreView.js.map +1 -1
  53. package/dist/shell/frame/AppMenu.d.ts +26 -3
  54. package/dist/shell/frame/AppMenu.js +106 -27
  55. package/dist/shell/frame/AppMenu.js.map +1 -1
  56. package/dist/shell/frame/FindBar.js +1 -1
  57. package/dist/shell/frame/FindBar.js.map +1 -1
  58. package/dist/shell/frame/Shell.d.ts +1 -0
  59. package/dist/shell/frame/Shell.js +10 -2
  60. package/dist/shell/frame/Shell.js.map +1 -1
  61. package/dist/shell/frame/Titlebar.d.ts +2 -1
  62. package/dist/shell/frame/Titlebar.js +5 -3
  63. package/dist/shell/frame/Titlebar.js.map +1 -1
  64. package/dist/shell/frame/WebPanel.js +4 -1
  65. package/dist/shell/frame/WebPanel.js.map +1 -1
  66. package/dist/shell/frame/accountSwitcher.css +10 -10
  67. package/dist/shell/frame/appMenu.css +19 -13
  68. package/dist/shell/frame/boot.js +2 -0
  69. package/dist/shell/frame/boot.js.map +1 -1
  70. package/dist/shell/frame/chromeTheme.d.ts +19 -1
  71. package/dist/shell/frame/chromeTheme.js +51 -14
  72. package/dist/shell/frame/chromeTheme.js.map +1 -1
  73. package/dist/shell/frame/deck.css +30 -30
  74. package/dist/shell/frame/edges.css +159 -0
  75. package/dist/shell/frame/index.d.ts +2 -2
  76. package/dist/shell/frame/index.js +1 -1
  77. package/dist/shell/frame/index.js.map +1 -1
  78. package/dist/shell/frame/mediaModel.d.ts +13 -0
  79. package/dist/shell/frame/mediaModel.js +14 -0
  80. package/dist/shell/frame/mediaModel.js.map +1 -1
  81. package/dist/shell/frame/menuMessages.en.json +43 -1
  82. package/dist/shell/frame/menuModel.d.ts +42 -0
  83. package/dist/shell/frame/menuModel.js +8 -0
  84. package/dist/shell/frame/menuModel.js.map +1 -1
  85. package/dist/shell/frame/nativeMenu.d.ts +5 -0
  86. package/dist/shell/frame/nativeMenu.js +178 -0
  87. package/dist/shell/frame/nativeMenu.js.map +1 -0
  88. package/dist/shell/frame/nativeMenuModel.d.ts +102 -0
  89. package/dist/shell/frame/nativeMenuModel.js +186 -0
  90. package/dist/shell/frame/nativeMenuModel.js.map +1 -0
  91. package/dist/shell/frame/paletteModel.js +3 -1
  92. package/dist/shell/frame/paletteModel.js.map +1 -1
  93. package/dist/shell/frame/paneTheme.css +1 -1
  94. package/dist/shell/frame/reportModel.d.ts +1 -1
  95. package/dist/shell/frame/reportModel.js +1 -1
  96. package/dist/shell/frame/reportModel.js.map +1 -1
  97. package/dist/shell/frame/shell.css +56 -51
  98. package/dist/shell/frame/state.d.ts +2 -0
  99. package/dist/shell/frame/state.js +3 -1
  100. package/dist/shell/frame/state.js.map +1 -1
  101. package/dist/shell/frame/themeStore.js +21 -6
  102. package/dist/shell/frame/themeStore.js.map +1 -1
  103. package/dist/shell/frame/workspaces.css +12 -12
  104. package/dist/shell/installController.d.ts +16 -1
  105. package/dist/shell/installController.js +31 -5
  106. package/dist/shell/installController.js.map +1 -1
  107. package/dist/shell/panel/Appearance.js +12 -3
  108. package/dist/shell/panel/Appearance.js.map +1 -1
  109. package/dist/shell/panel/Settings.d.ts +1 -1
  110. package/dist/shell/panel/Settings.js +4 -3
  111. package/dist/shell/panel/Settings.js.map +1 -1
  112. package/dist/shell/panel/StoreSection.d.ts +2 -1
  113. package/dist/shell/panel/StoreSection.js +31 -1
  114. package/dist/shell/panel/StoreSection.js.map +1 -1
  115. package/dist/shell/panel/StringPacks.d.ts +12 -3
  116. package/dist/shell/panel/StringPacks.js +33 -8
  117. package/dist/shell/panel/StringPacks.js.map +1 -1
  118. package/dist/shell/panel/backup.css +1 -1
  119. package/dist/shell/panel/builder.css +48 -40
  120. package/dist/shell/panel/make.css +8 -7
  121. package/dist/shell/panel/panel.css +401 -401
  122. package/dist/shell/panel/panelMessages.d.ts +41 -1
  123. package/dist/shell/panel/panelMessages.js +13 -1
  124. package/dist/shell/panel/panelMessages.js.map +1 -1
  125. package/dist/shell/panel/plugins.css +5 -5
  126. package/dist/shell/panel/strings.css +1 -1
  127. package/dist/shell/store.css +34 -14
  128. package/dist/shell/storeMessages.d.ts +24 -0
  129. package/dist/shell/storeMessages.js +24 -0
  130. package/dist/shell/storeMessages.js.map +1 -1
  131. package/dist/shell/storeModel.d.ts +31 -7
  132. package/dist/shell/storeModel.js +15 -2
  133. package/dist/shell/storeModel.js.map +1 -1
  134. package/dist/shell/stringPacksModel.js +7 -2
  135. package/dist/shell/stringPacksModel.js.map +1 -1
  136. package/icons.schema.json +9 -0
  137. package/locales/ar.json +109 -2
  138. package/locales/bg.json +109 -2
  139. package/locales/bn.json +109 -2
  140. package/locales/ca.json +109 -2
  141. package/locales/cs.json +109 -2
  142. package/locales/da.json +109 -2
  143. package/locales/de.json +109 -2
  144. package/locales/el.json +109 -2
  145. package/locales/en.json +108 -2
  146. package/locales/es.json +109 -2
  147. package/locales/eu.json +109 -2
  148. package/locales/fa.json +109 -2
  149. package/locales/fi.json +109 -2
  150. package/locales/fil.json +109 -2
  151. package/locales/fr.json +109 -2
  152. package/locales/ga.json +109 -2
  153. package/locales/gl.json +109 -2
  154. package/locales/gu.json +109 -2
  155. package/locales/ha.json +109 -2
  156. package/locales/he.json +109 -2
  157. package/locales/hi.json +109 -2
  158. package/locales/hr.json +109 -2
  159. package/locales/hu.json +110 -2
  160. package/locales/id.json +109 -2
  161. package/locales/ig.json +109 -2
  162. package/locales/it.json +109 -2
  163. package/locales/ja.json +109 -2
  164. package/locales/kn.json +109 -2
  165. package/locales/ko.json +109 -2
  166. package/locales/mr.json +109 -2
  167. package/locales/ms.json +109 -2
  168. package/locales/nb.json +109 -2
  169. package/locales/nl.json +109 -2
  170. package/locales/pl.json +109 -2
  171. package/locales/pt.json +109 -2
  172. package/locales/ro.json +109 -2
  173. package/locales/ru.json +109 -2
  174. package/locales/sk.json +109 -2
  175. package/locales/sr.json +109 -2
  176. package/locales/sv.json +109 -2
  177. package/locales/ta.json +109 -2
  178. package/locales/th.json +109 -2
  179. package/locales/tr.json +109 -2
  180. package/locales/uk.json +109 -2
  181. package/locales/ur.json +109 -2
  182. package/locales/vi.json +109 -2
  183. package/locales/yo.json +109 -2
  184. package/locales/zh-Hans.json +109 -2
  185. package/locales/zh-Hant.json +109 -2
  186. package/package.json +2 -3
  187. package/store-catalog.schema.json +18 -5
  188. package/theme.schema.json +9 -0
  189. package/windows/installer.nsi +22 -2
  190. package/cli/privacy.mjs +0 -768
package/README.md CHANGED
@@ -120,9 +120,8 @@ const Shell = createShell({ ...site, panel: { id: 'app', title: 'wayBetter', com
120
120
  - `waybetter store sign [--init] --key <private key>` / `waybetter store verify --pub <key file>`: Ed25519 signature over the exact catalog bytes; any listed public key verifies (rotation). The private key is never printed or copied.
121
121
  - `waybetter strings check <pack> [--inventory <file>] [--verbose]`: validates a string pack (data that replaces the wrapped site's own UI strings: `waybetter/runtime/stringPacks`, `waybetter/runtime/stringPackHook`, `waybetter/runtime/textNet`) against the site's string inventory and prints its coverage; the inventories otherwise come from `waybetter.strings.json` in the app's root.
122
122
  - `waybetter i18n check [--write] [--verbose] [locale…]`: checks an app's catalogs (and the framework's in the same languages) with the `waybetter/i18n/check` rules, bundled plugins' inline English and the generated files; `--write` regenerates `languages.json` and the native localization files (macOS, Linux). `waybetter i18n extract [--missing]`: keys the code looks up that English lacks, and English keys without a literal lookup. Both read `waybetter.i18n.json` in the app's root (`locales`, `sources`, `nativeSources`, `bundledPlugins`, `runtimeKeys`, `supported`, `native`; schema: `i18n-config.schema.json`). `supported` lists the languages in the supported tier; every other catalog gets `tier: "community"` in the generated `languages.json` (`languages.schema.json`), and an app resolves its automatic language choices only to supported ones (`createLanguage({ supported })`).
123
- - `waybetter release windows|macos`: signed native releases and the update manifest (`waybetter.release.json`). The Windows build runs path-free (below) and every artifact must pass the privacy scan before anything is copied or signed.
123
+ - `waybetter release windows|macos`: signed native releases and the update manifest (`waybetter.release.json`). The Windows build runs path-free (below); `WAYBETTER_RELEASE_CHECK` can name a command run on every artifact before anything is copied or signed.
124
124
  - `waybetter build-env -- <command>`: runs a build (`tauri build`, `cargo build`) so the binary holds none of the machine's paths: the home folder, the workspace, `CARGO_HOME`, `RUSTUP_HOME` and `CARGO_TARGET_DIR` become `/home`, `/src`, `/cargo`, `/rustup` and `/target` (`--remap-path-prefix` appended to the flags already in `RUSTFLAGS`/`CARGO_ENCODED_RUSTFLAGS`, `/PDBALTPATH` on MSVC; for C and C++ a build script compiles, `-ffile-prefix-map` on gcc and clang and, on MSVC, `/d1trimfile:<root>` in `CL` and `/PDBALTPATH` in `LINK`, which reach the cc crate, CMake and MSBuild alike). Without a command it prints that environment (`--json`).
125
- - `waybetter privacy-scan <paths…> [--terms <file>] [--tracked] [--history]`: searches files, binaries (UTF-8 and UTF-16), archives (zip, tar, gzip, zstd, .deb, DMG; NSIS installers, AppImages and xz with 7-Zip or the matching tool), git-tracked files and a repository's whole history for forbidden terms: the lines of a file kept outside every repository (`--terms`, `WAYBETTER_FORBIDDEN_TERMS`, or `"forbiddenTerms"` in `waybetter.release.json`) plus this machine's account name. A name is a hit as a whole word in any case; a name of three characters or fewer only in a path or as the capitalized word. Output shows file and line or offset with the term masked (`m***`); exit 1 on a hit, 2 when a container could not be opened. Reviewed false positives: `"privacyAllowlist"`, lines of `<file glob>: <regex over the context, the term written <term>>`.
126
125
  - `windows/languages/<Language>.nsh`: the Windows installer's messages for the languages Tauri does not ship, next to the installer template (`windows/installer.nsi`).
127
126
 
128
127
  ## Plugin UI kit (`runtime/ui`)
package/bin/waybetter.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // The `waybetter` command: `waybetter store build|sign|verify` (cli/store.mjs),
3
3
  // `waybetter release windows|macos|linux` (cli/release.mjs), `waybetter manifests` (cli/manifests.mjs), `waybetter i18n check|extract`
4
- // (cli/i18n.mjs), `waybetter privacy-scan` (cli/privacy.mjs) and `waybetter build-env`
4
+ // (cli/i18n.mjs), `waybetter build-env`
5
5
  // (cli/buildenv.mjs), `waybetter dev` (cli/dev.mjs: watchers + tauri dev), `waybetter strings check`
6
6
  // (cli/strings.mjs).
7
7
  import { readFileSync } from 'node:fs';
@@ -9,7 +9,6 @@ import { BUILD_ENV_USAGE, buildEnvCommand } from '../cli/buildenv.mjs';
9
9
  import { DEV_USAGE, devCommand } from '../cli/dev.mjs';
10
10
  import { MANIFESTS_USAGE, manifestsCommand } from '../cli/manifests.mjs';
11
11
  import { I18N_USAGE, i18nCommand } from '../cli/i18n.mjs';
12
- import { PRIVACY_USAGE, privacyCommand } from '../cli/privacy.mjs';
13
12
  import { STORE_USAGE, storeCommand } from '../cli/store.mjs';
14
13
  import { STRINGS_USAGE, stringsCommand } from '../cli/strings.mjs';
15
14
  import { RELEASE_USAGE, releaseCommand } from '../cli/release.mjs';
@@ -26,8 +25,6 @@ try {
26
25
  process.exitCode = await i18nCommand(rest);
27
26
  } else if (area === 'strings') {
28
27
  process.exitCode = await stringsCommand(rest);
29
- } else if (area === 'privacy-scan') {
30
- process.exitCode = privacyCommand(rest);
31
28
  } else if (area === 'build-env') {
32
29
  process.exitCode = buildEnvCommand(rest);
33
30
  } else if (area === 'dev') {
@@ -35,7 +32,7 @@ try {
35
32
  } else if (area === '--version' || area === '-v') {
36
33
  console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
37
34
  } else {
38
- console.log(`${STORE_USAGE}\n\n${RELEASE_USAGE}\n\n${MANIFESTS_USAGE}\n\n${I18N_USAGE}\n\n${STRINGS_USAGE}\n\n${PRIVACY_USAGE}\n\n${BUILD_ENV_USAGE}
35
+ console.log(`${STORE_USAGE}\n\n${RELEASE_USAGE}\n\n${MANIFESTS_USAGE}\n\n${I18N_USAGE}\n\n${STRINGS_USAGE}\n\n${BUILD_ENV_USAGE}
39
36
 
40
37
  ${DEV_USAGE}`);
41
38
  if (area && area !== '--help' && area !== '-h') process.exitCode = 1;
package/cli/buildenv.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
  // waybetter build-env -- tauri build run a command with that environment
19
19
  // waybetter build-env [--json] show it
20
20
  //
21
- // `waybetter release windows` builds with it. It is not a proof: `waybetter privacy-scan` is.
21
+ // `waybetter release windows` builds with it.
22
22
  import { spawnSync } from 'node:child_process';
23
23
  import { existsSync } from 'node:fs';
24
24
  import { homedir } from 'node:os';
package/cli/release.mjs CHANGED
@@ -17,17 +17,13 @@
17
17
  // "prepareCommand": "bun install --frozen-lockfile", // remote/WSL builds: runs first in the unpacked sources
18
18
  // "distribution": { "publisher": "CRT", "id": "CRT.Name", "homepage": "https://...", "license": "...", "shortDescription": "...", "downloadBase": "https://..." },
19
19
  // // manifests written after each release step (see cli/manifests.mjs)
20
- // "exportExclude": ["verification"], // remote/WSL builds: app-repo paths left out of the upload
21
- // "forbiddenTerms": "~/.better-twttr-keys/forbidden-names.txt", // optional, outside the repo
22
- // "privacyAllowlist": "privacy-allowlist.txt" // optional, reviewed false positives
20
+ // "exportExclude": ["verification"] // remote/WSL builds: app-repo paths left out of the upload
23
21
  // }
24
22
  //
25
- // Privacy: the Windows build runs with the path-free build environment (cli/buildenv.mjs), and
26
- // every artifact (the installer and what it installs, the main binary, the front end, the dmg and
27
- // the updater bundle) goes through `waybetter privacy-scan` (cli/privacy.mjs) before anything is
28
- // copied, signed or written: a forbidden term, or a container that cannot be opened (the NSIS
29
- // installer needs 7-Zip), stops the release. The terms are the file "forbiddenTerms" names (or
30
- // WAYBETTER_FORBIDDEN_TERMS) plus this machine's account name and home folder name.
23
+ // The Windows build runs with the path-free build environment (cli/buildenv.mjs). Every artifact
24
+ // (the installer, the main binary, the front end, the dmg and the updater bundle) goes through the
25
+ // optional release check (cli/releasecheck.mjs, WAYBETTER_RELEASE_CHECK) before anything is
26
+ // copied, signed or written.
31
27
  //
32
28
  // The private key is read only into the build's environment (Windows) or handed to the Tauri
33
29
  // signer by path (macOS). Nothing here prints, copies or moves it.
@@ -37,7 +33,7 @@ import { homedir } from 'node:os';
37
33
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
38
34
  import { pathToFileURL } from 'node:url';
39
35
  import { pathFreeBuildEnv } from './buildenv.mjs';
40
- import { assertClean } from './privacy.mjs';
36
+ import { runReleaseCheck } from './releasecheck.mjs';
41
37
  import { CHANNELS, resolveSpec, writeManifests } from './manifests.mjs';
42
38
  import { buildLinuxWsl, buildMacRemote, scanBuilt } from './remote.mjs';
43
39
 
@@ -65,10 +61,9 @@ export const RELEASE_USAGE = `Usage:
65
61
  linux-x86_64 to latest.json (run the Windows step first).
66
62
 
67
63
  Config: ${CONFIG_FILE} in the app's root (product, artifact, desktopDir, keysDir, keysDirEnv,
68
- cdnBase, apiBase, outDir, optional manifestValidator, forbiddenTerms, privacyAllowlist). Env:
69
- CARGO_TARGET_DIR, CDN_BASE, DEPLOY_TARGET, CDN_DIR, RELEASES_DIR, WAYBETTER_FORBIDDEN_TERMS,
70
- WAYBETTER_7Z. The build is path-free (waybetter build-env) and every artifact must pass
71
- waybetter privacy-scan. Nothing is ever uploaded.`;
64
+ cdnBase, apiBase, outDir, optional manifestValidator). Env: CARGO_TARGET_DIR, CDN_BASE,
65
+ DEPLOY_TARGET, CDN_DIR, RELEASES_DIR, WAYBETTER_RELEASE_CHECK (an optional command run on every
66
+ artifact first). The build is path-free (waybetter build-env). Nothing is ever uploaded.`;
72
67
 
73
68
  class ReleaseError extends Error {}
74
69
  const fail = message => { throw new ReleaseError(message); };
@@ -127,8 +122,6 @@ export function normalizeReleaseConfig(raw, root) {
127
122
  distribution: raw.distribution,
128
123
  exportExclude: Array.isArray(raw.exportExclude) ? raw.exportExclude.map(String) : [],
129
124
  manifestValidator: validator && { module: at(validator.module), export: validator.export, hint: validator.hint },
130
- forbiddenTerms: raw.forbiddenTerms === undefined ? undefined : at(home(text('forbiddenTerms'))),
131
- privacyAllowlist: raw.privacyAllowlist === undefined ? undefined : at(text('privacyAllowlist')),
132
125
  };
133
126
  }
134
127
 
@@ -179,9 +172,9 @@ function readVersion(config) {
179
172
  return { version, productName: conf.productName, binary, dist };
180
173
  }
181
174
 
182
- /** The release gate: no artifact may hold a forbidden term (see cli/privacy.mjs). */
175
+ /** The optional release check (cli/releasecheck.mjs) over every artifact that exists. */
183
176
  function gate(config, paths, env, log) {
184
- assertClean(paths.filter(path => path && existsSync(path)), { terms: config.forbiddenTerms, allowlist: config.privacyAllowlist, cwd: config.root, env, log, label: 'release: privacy-scan' });
177
+ runReleaseCheck(paths.filter(path => path && existsSync(path)), { env, log, label: 'release: check' });
185
178
  }
186
179
 
187
180
  function option(args, name) {
@@ -0,0 +1,15 @@
1
+ // An optional, owner-supplied check run on every artifact before a release copies, signs or writes
2
+ // anything: WAYBETTER_RELEASE_CHECK names a command (run through the shell) that gets the artifact
3
+ // paths as arguments. A non-zero exit stops the release. Unset, nothing runs.
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ export const RELEASE_CHECK_ENV = 'WAYBETTER_RELEASE_CHECK';
7
+
8
+ export function runReleaseCheck(paths, { env = process.env, log = console.log, label = 'release check' } = {}) {
9
+ const command = env[RELEASE_CHECK_ENV];
10
+ if (!command || !paths.length) return;
11
+ const quoted = paths.map(path => `'${String(path).replaceAll("'", "'\\''")}'`).join(' ');
12
+ log(`${label}: ${paths.length} file(s)`);
13
+ const result = spawnSync(`${command} ${quoted}`, { shell: true, stdio: 'inherit', env });
14
+ if (result.status !== 0) throw new Error(`${label} failed (exit ${result.status ?? result.signal})`);
15
+ }
package/cli/remote.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // Remote and WSL builds for `waybetter release macos|linux`: the committed state of an app (its
2
2
  // git HEAD plus every submodule at the commit the app records) is exported, sent to the machine
3
3
  // that builds it, built there through `waybetter build-env` (no build path in the binary) and the
4
- // artifacts come back to be scanned (`waybetter privacy-scan`) and, later, signed on this machine.
4
+ // artifacts come back to be checked (the optional release check) and, later, signed on this machine.
5
5
  // Nothing here touches the working tree the developer has open, and no key ever leaves this
6
6
  // machine: the remote build is unsigned (updater artifacts off), signing is `waybetter release`.
7
7
  //
@@ -18,7 +18,7 @@ import { spawnSync } from 'node:child_process';
18
18
  import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
19
19
  import { tmpdir } from 'node:os';
20
20
  import { basename, join, relative, resolve } from 'node:path';
21
- import { assertClean } from './privacy.mjs';
21
+ import { runReleaseCheck } from './releasecheck.mjs';
22
22
 
23
23
  class RemoteError extends Error {}
24
24
  const fail = message => { throw new RemoteError(message); };
@@ -250,11 +250,11 @@ export function buildLinuxWsl({ config, distro = 'Ubuntu-22.04', remoteDir, ref
250
250
 
251
251
  const relTauri = (config, name) => `${relative(config.root, config.tauriDir).replaceAll('\\', '/')}/${name}`;
252
252
 
253
- /** Scans built artifacts here (the terms never leave this machine) and prints their sizes. */
253
+ /** Prints the built artifacts' sizes and runs the optional release check on them here. */
254
254
  export function scanBuilt(config, files, { env = process.env, log = console.log } = {}) {
255
255
  for (const file of files) {
256
256
  if (!existsSync(file)) fail(`expected build output missing: ${file}`);
257
257
  log(`built: ${basename(file)} ${(statSync(file).size / 1048576).toFixed(1)} MB`);
258
258
  }
259
- assertClean(files, { terms: config.forbiddenTerms, allowlist: config.privacyAllowlist, cwd: config.root, env, log, label: 'release: privacy-scan' });
259
+ runReleaseCheck(files, { env, log, label: 'release: check' });
260
260
  }
package/cli/store.mjs CHANGED
@@ -15,7 +15,19 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
15
15
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
16
16
  import { pathToFileURL } from 'node:url';
17
17
 
18
+ /**
19
+ * Two catalog files, one source. `catalog.json` is format 2 and lists only the kinds every app
20
+ * since the first release knows (theme, plugin, strings): an app from before a kind existed refuses
21
+ * a whole catalog that lists it. `catalog.v3.json` is format 3, lists everything (icons, builtin, ...)
22
+ * and is read first by apps that know it; they fall back to `catalog.json` when it is not published.
23
+ * `waybetter store build` writes both from the same items; `sign` and `verify` handle both.
24
+ */
18
25
  export const CATALOG_FORMAT = 2;
26
+ export const CATALOG_FORMAT_NEXT = 3;
27
+ export const CATALOG_FILE = 'catalog.json';
28
+ export const CATALOG_FILE_NEXT = 'catalog.v3.json';
29
+ /** The kinds format 2 may list; a newer kind is format 3 only. */
30
+ export const LEGACY_KINDS = ['theme', 'plugin', 'strings'];
19
31
  export const DEFAULT_BASE = 'https://waybetter.fyi/store/';
20
32
  /** The framework's download hosts (waybetter-host `DEFAULT_HOSTS`). */
21
33
  export const DEFAULT_HOSTS = [
@@ -25,6 +37,9 @@ export const DEFAULT_HOSTS = [
25
37
  ];
26
38
  export const PERMISSIONS = ['storage', 'ui', 'clipboard', 'account', 'users', 'commands', 'network', 'network:none'];
27
39
  const MAX_BYTES = 2 * 1024 * 1024;
40
+ /** An icon pack file (the runtime's `ICON_LIMITS.packBytes`). */
41
+ const MAX_ICONS_BYTES = 120_000;
42
+ const KINDS = ['theme', 'plugin', 'strings', 'icons', 'builtin'];
28
43
  const MAX_ITEMS = 500;
29
44
  const MAX_CATALOG_BYTES = 1024 * 1024;
30
45
  // SPKI DER prefix of an Ed25519 public key; the raw 32 bytes follow it.
@@ -84,7 +99,7 @@ export function itemProblems(item, hosts) {
84
99
  if (!item || typeof item !== 'object' || Array.isArray(item)) return ['not an object'];
85
100
  for (const key of Object.keys(item)) if (!ITEM_FIELDS.has(key)) bad.push(`unknown field ${key}`);
86
101
  if (!Array.isArray(item.apps) || !item.apps.length || item.apps.length > 16 || !item.apps.every(validAppId) || new Set(item.apps).size !== item.apps.length) bad.push('apps');
87
- if (item.kind !== 'theme' && item.kind !== 'plugin' && item.kind !== 'strings') bad.push('kind');
102
+ if (!KINDS.includes(item.kind)) bad.push('kind');
88
103
  // Any item may name its licence and credit its authors; only a string pack has a language.
89
104
  if ((item.license !== undefined && (!text(item.license, 60) || !String(item.license).trim())) || (item.credits !== undefined && !text(item.credits, 600))) bad.push('license or credits');
90
105
  if (item.kind !== 'strings' && item.language !== undefined) bad.push('language');
@@ -92,10 +107,18 @@ export function itemProblems(item, hosts) {
92
107
  if (!text(item.name, 60) || !String(item.name).trim() || !text(item.author ?? '', 60) || !text(item.description ?? '', 600)) bad.push('name, author or description');
93
108
  if (!text(item.version, 32) || !item.version || !text(item.category ?? '', 32)) bad.push('version or category');
94
109
  if (item.tags !== undefined && (!Array.isArray(item.tags) || item.tags.length > 12 || !item.tags.every(tag => text(tag, 32)))) bad.push('tags');
95
- if (typeof item.url !== 'string' || !item.url.startsWith('https://') || (hosts && !allowedUrl(item.url, hosts))) bad.push('url (host not allowed)');
110
+ if (item.kind === 'builtin') {
111
+ // A feature that ships inside the app (an area plugin): nothing to download, so nothing to point at or hash.
112
+ for (const key of ['url', 'sha256', 'size', 'file', 'permissions', 'colors', 'language']) if (item[key] !== undefined) bad.push('built-in item fields');
113
+ } else {
114
+ if (typeof item.url !== 'string' || !item.url.startsWith('https://') || (hosts && !allowedUrl(item.url, hosts))) bad.push('url (host not allowed)');
115
+ }
96
116
  if (item.preview !== undefined && (typeof item.preview !== 'string' || !item.preview.startsWith('https://') || (hosts && !allowedUrl(item.preview, hosts)))) bad.push('preview');
97
- if (typeof item.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(item.sha256)) bad.push('sha256');
98
- if (!Number.isInteger(item.size) || item.size <= 0 || item.size > MAX_BYTES) bad.push('size');
117
+ if (item.kind !== 'builtin') {
118
+ if (typeof item.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(item.sha256)) bad.push('sha256');
119
+ if (!Number.isInteger(item.size) || item.size <= 0 || item.size > (item.kind === 'icons' ? MAX_ICONS_BYTES : MAX_BYTES)) bad.push('size');
120
+ }
121
+ if (item.kind === 'icons' && (item.file !== undefined || item.permissions !== undefined || item.colors !== undefined)) bad.push('icon pack fields');
99
122
  if (item.kind === 'plugin') {
100
123
  if (!validFileName(item.file) || item.colors != null) bad.push('plugin file');
101
124
  const p = item.permissions;
@@ -118,7 +141,7 @@ export function catalogProblems(catalog, { app, hosts = DEFAULT_HOSTS } = {}) {
118
141
  const out = [];
119
142
  if (!catalog || typeof catalog !== 'object') return ['catalog: not an object'];
120
143
  for (const key of Object.keys(catalog)) if (!['format', 'name', 'generatedAt', 'items'].includes(key)) out.push(`catalog: unknown field ${key}`);
121
- if (catalog.format !== CATALOG_FORMAT) out.push(`catalog: format must be ${CATALOG_FORMAT}`);
144
+ if (catalog.format !== CATALOG_FORMAT && catalog.format !== CATALOG_FORMAT_NEXT) out.push(`catalog: format must be ${CATALOG_FORMAT} or ${CATALOG_FORMAT_NEXT}`);
122
145
  if (typeof catalog.name !== 'string' || typeof catalog.generatedAt !== 'string') out.push('catalog: name and generatedAt are strings');
123
146
  if (!Array.isArray(catalog.items)) return [...out, 'catalog: items must be a list'];
124
147
  if (catalog.items.length > MAX_ITEMS) out.push(`catalog: more than ${MAX_ITEMS} items`);
@@ -127,6 +150,7 @@ export function catalogProblems(catalog, { app, hosts = DEFAULT_HOSTS } = {}) {
127
150
  for (const item of catalog.items) {
128
151
  const where = `item ${item?.kind}:${item?.id}`;
129
152
  for (const problem of itemProblems(item, app && item?.apps?.includes(app) ? hosts : undefined)) out.push(`${where}: invalid ${problem}`);
153
+ if (catalog.format === CATALOG_FORMAT && !LEGACY_KINDS.includes(item?.kind)) out.push(`${where}: a format ${CATALOG_FORMAT} catalog lists only ${LEGACY_KINDS.join(', ')} (older apps refuse the whole file otherwise); this kind belongs in ${CATALOG_FILE_NEXT}`);
130
154
  for (const a of Array.isArray(item?.apps) ? item.apps : []) {
131
155
  const id = `${a}/${item.kind}/${item.id}`;
132
156
  if (ids.has(id)) out.push(`${where}: duplicate for app ${a}`);
@@ -146,12 +170,15 @@ export function catalogProblems(catalog, { app, hosts = DEFAULT_HOSTS } = {}) {
146
170
  /**
147
171
  * Builds `config.app`'s part of the shared catalog in `out`: writes the files the config returns,
148
172
  * replaces the app's items (other apps' items stay as they are), removes files only the app's old
149
- * items used, validates the whole catalog and writes `catalog.json` (sign it afterwards).
173
+ * items used, validates the whole catalog and writes `catalog.v3.json` (every item) and `catalog.json`
174
+ * (the kinds older apps know), sign both afterwards.
150
175
  *
151
176
  * config: { app, name?, base?, out?, hosts?, items(context) → [{ kind, id, name, author, version,
152
177
  * description, category, tags, file (plugins), permissions (plugins), colors (themes), language
153
178
  * (string packs), license, credits,
154
179
  * path ('plugins/x.plugin.js'), bytes (Buffer|string), preview?: { path, bytes } }] }
180
+ * Kinds: theme, plugin, strings, icons (an icon pack, `icons/<id>.icons.json`) and builtin (a
181
+ * feature that ships inside the app, shown in the Store as built in: no path or bytes).
155
182
  */
156
183
  export async function buildStore(config, options = {}) {
157
184
  const app = config.app;
@@ -160,11 +187,14 @@ export async function buildStore(config, options = {}) {
160
187
  if (!/^https:\/\/[^/]+\/(.*\/)?$/.test(base)) throw new Error('base must be an https URL ending in /');
161
188
  const out = resolve(options.out ?? config.out ?? '.');
162
189
  const hosts = config.hosts ?? DEFAULT_HOSTS;
163
- const catalogPath = join(out, 'catalog.json');
164
- const previous = existsSync(catalogPath) ? JSON.parse(readFileSync(catalogPath, 'utf8')) : null;
190
+ const catalogPath = join(out, CATALOG_FILE);
191
+ const nextPath = join(out, CATALOG_FILE_NEXT);
192
+ // The full list (format 3) is what to start from: it has every app's items of every kind.
193
+ const source = existsSync(nextPath) ? nextPath : catalogPath;
194
+ const previous = existsSync(source) ? JSON.parse(readFileSync(source, 'utf8')) : null;
165
195
  const kept = [];
166
196
  const oldFiles = new Set();
167
- for (const item of previous?.format === CATALOG_FORMAT ? previous.items : []) {
197
+ for (const item of previous?.format === CATALOG_FORMAT || previous?.format === CATALOG_FORMAT_NEXT ? previous.items : []) {
168
198
  if (!item.apps?.includes(app)) { kept.push(item); continue; }
169
199
  for (const url of [item.url, item.preview]) if (url?.startsWith(base)) oldFiles.add(url.slice(base.length));
170
200
  const others = item.apps.filter(a => a !== app);
@@ -181,12 +211,16 @@ export async function buildStore(config, options = {}) {
181
211
  const files = new Map();
182
212
  const toBytes = value => (Buffer.isBuffer(value) ? value : Buffer.from(String(value), 'utf8'));
183
213
  for (const entry of entries) {
184
- const bytes = toBytes(entry.bytes);
185
- files.set(entry.path, { target: inside(entry.path), bytes });
186
- const item = { apps: [app], kind: entry.kind, id: entry.id, name: entry.name, author: entry.author ?? '', version: entry.version, description: entry.description ?? '', category: entry.category ?? '', tags: entry.tags ?? [], url: `${base}${entry.path}` };
187
- if (entry.kind === 'plugin') item.file = entry.file;
188
- item.sha256 = sha256(bytes);
189
- item.size = bytes.length;
214
+ const item = { apps: [app], kind: entry.kind, id: entry.id, name: entry.name, author: entry.author ?? '', version: entry.version, description: entry.description ?? '', category: entry.category ?? '', tags: entry.tags ?? [] };
215
+ // A built-in item has no file: the app already has it.
216
+ if (entry.kind !== 'builtin') {
217
+ const bytes = toBytes(entry.bytes);
218
+ files.set(entry.path, { target: inside(entry.path), bytes });
219
+ item.url = `${base}${entry.path}`;
220
+ if (entry.kind === 'plugin') item.file = entry.file;
221
+ item.sha256 = sha256(bytes);
222
+ item.size = bytes.length;
223
+ }
190
224
  if (entry.preview) {
191
225
  files.set(entry.preview.path, { target: inside(entry.preview.path), bytes: toBytes(entry.preview.bytes) });
192
226
  item.preview = `${base}${entry.preview.path}`;
@@ -199,23 +233,27 @@ export async function buildStore(config, options = {}) {
199
233
  items.push(item);
200
234
  }
201
235
  const catalog = {
202
- format: CATALOG_FORMAT,
236
+ format: CATALOG_FORMAT_NEXT,
203
237
  name: config.name ?? previous?.name ?? 'wayBetter Store',
204
238
  generatedAt: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
205
239
  items: [...kept, ...items],
206
240
  };
207
241
  // Nothing is written unless the whole catalog is valid.
208
- const problems = catalogProblems(catalog, { app, hosts });
242
+ // The same items twice: everything for apps that know the newer kinds, and what every app knows.
243
+ const legacy = { ...catalog, format: CATALOG_FORMAT, items: catalog.items.filter(item => LEGACY_KINDS.includes(item.kind)) };
244
+ const problems = [...catalogProblems(catalog, { app, hosts }), ...catalogProblems(legacy, { app, hosts })];
209
245
  if (problems.length) throw new Error(`the catalog would be invalid:\n ${problems.join('\n ')}`);
210
246
  const json = `${JSON.stringify(catalog, null, 2)}\n`;
247
+ const legacyJson = `${JSON.stringify(legacy, null, 2)}\n`;
211
248
  if (Buffer.byteLength(json) > MAX_CATALOG_BYTES) throw new Error('the catalog is larger than apps accept (1 MiB)');
212
249
  for (const { target, bytes } of files.values()) { mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, bytes); }
213
250
  // Files this app's previous build wrote and no item uses any more (never another app's files).
214
251
  const stillUsed = new Set(kept.flatMap(item => [item.url, item.preview]).filter(url => url?.startsWith(base)).map(url => url.slice(base.length)));
215
252
  for (const path of oldFiles) if (!files.has(path) && !stillUsed.has(path)) rmSync(inside(path), { force: true });
216
253
  mkdirSync(out, { recursive: true });
217
- writeFileSync(catalogPath, json);
218
- return { catalog, path: catalogPath, items };
254
+ writeFileSync(nextPath, json);
255
+ writeFileSync(catalogPath, legacyJson);
256
+ return { catalog, legacy, path: catalogPath, nextPath, items };
219
257
  }
220
258
 
221
259
  // ---- Keys and signatures ----------------------------------------------------------------------
@@ -286,6 +324,12 @@ function parse(argv) {
286
324
  return out;
287
325
  }
288
326
 
327
+ /** `catalog.json` and, next to it, `catalog.v3.json` when it exists (one source, two files); any other file alone. */
328
+ export function catalogFiles(file) {
329
+ const next = join(dirname(file), CATALOG_FILE_NEXT);
330
+ return file.endsWith(CATALOG_FILE) && !file.endsWith(CATALOG_FILE_NEXT) && existsSync(next) ? [file, next] : [file];
331
+ }
332
+
289
333
  /** Runs `waybetter store …`; returns the lines to print. Throws on errors. */
290
334
  export async function storeCommand(argv) {
291
335
  const [command, ...rest] = argv;
@@ -297,7 +341,7 @@ export async function storeCommand(argv) {
297
341
  const config = (await import(pathToFileURL(resolve(options.config)).href)).default;
298
342
  const { path, items } = await buildStore(config, { out: options.out, base: options.base });
299
343
  const count = kind => items.filter(item => item.kind === kind).length;
300
- return [`store: ${config.app}: ${count('plugin')} plugins, ${count('theme')} themes → ${path}`, 'Now sign it: waybetter store sign <catalog.json> --key <private key>'];
344
+ return [`store: ${config.app}: ${count('plugin')} plugins, ${count('theme')} themes, ${count('icons')} icon packs, ${count('builtin')} built-in → ${path} and ${CATALOG_FILE_NEXT}`, 'Now sign both: waybetter store sign <catalog.json> --key <private key>'];
301
345
  }
302
346
  if (command === 'sign') {
303
347
  if (!key) throw new Error('sign needs --key (or WAYBETTER_STORE_KEY)');
@@ -307,18 +351,26 @@ export async function storeCommand(argv) {
307
351
  return [`${created ? 'Created the store key' : 'Store key already exists'}; public key ${publicKey} → ${options.pub[0]}`];
308
352
  }
309
353
  if (!existsSync(key)) throw new Error(`no store key at ${key} (create it once with --init)`);
310
- const publicKey = signFile(catalog, key);
311
- const keys = options.pub.length ? options.pub.map(file => readFileSync(file, 'utf8').trim()) : [publicKey];
312
- if (!verifyFile(catalog, keys)) throw new Error(`signature of ${catalog} does NOT verify with ${options.pub.join(', ') || 'the key'}`);
313
- return [`Signed and verified ${catalog}.sig`];
354
+ const lines = [];
355
+ for (const file of catalogFiles(catalog)) {
356
+ const publicKey = signFile(file, key);
357
+ const keys = options.pub.length ? options.pub.map(pub => readFileSync(pub, 'utf8').trim()) : [publicKey];
358
+ if (!verifyFile(file, keys)) throw new Error(`signature of ${file} does NOT verify with ${options.pub.join(', ') || 'the key'}`);
359
+ lines.push(`Signed and verified ${file}.sig`);
360
+ }
361
+ return lines;
314
362
  }
315
363
  if (command === 'verify') {
316
364
  if (!options.pub.length) throw new Error('verify needs --pub');
317
365
  const keys = options.pub.map(file => readFileSync(file, 'utf8').trim());
318
- if (!verifyFile(catalog, keys)) throw new Error(`signature of ${catalog} does NOT verify with ${options.pub.join(', ')}`);
319
- const problems = catalogProblems(JSON.parse(readFileSync(catalog, 'utf8')), { app: options.app });
320
- if (problems.length) throw new Error(`${catalog} is signed but invalid:\n ${problems.join('\n ')}`);
321
- return [`Verified ${catalog}.sig`];
366
+ const lines = [];
367
+ for (const file of catalogFiles(catalog)) {
368
+ if (!existsSync(`${file}.sig`) || !verifyFile(file, keys)) throw new Error(`signature of ${file} does NOT verify with ${options.pub.join(', ')}`);
369
+ const problems = catalogProblems(JSON.parse(readFileSync(file, 'utf8')), { app: options.app });
370
+ if (problems.length) throw new Error(`${file} is signed but invalid:\n ${problems.join('\n ')}`);
371
+ lines.push(`Verified ${file}.sig`);
372
+ }
373
+ return lines;
322
374
  }
323
375
  throw new Error(USAGE);
324
376
  }
package/cli/strings.mjs CHANGED
@@ -77,6 +77,8 @@ export async function checkPack(packFile, inventories, { verbose = false } = {})
77
77
  const coverage = rules.coverage(match.inventory, compiled);
78
78
  lines.push(`${pack.name} (${pack.language}) for ${pack.site}/${pack.catalog}, inventory build ${match.inventory.build ?? 'unknown'}`);
79
79
  lines.push(`${report.total} strings: ${report.valid} valid, ${report.rejected.length} refused, ${report.unknown.length} unknown`);
80
+ if (pack.when?.languages) lines.push(`Applies only while the site shows: ${pack.when.languages.join(', ')}`);
81
+ if (pack.rewrite) lines.push(`Rewords ${Object.keys(pack.rewrite.words).length} words inside the site's own text: ${Object.keys(pack.rewrite.words).slice(0, 6).join(', ')}${Object.keys(pack.rewrite.words).length > 6 ? ', …' : ''}`);
80
82
  const list = (items, render) => {
81
83
  for (const item of verbose ? items : items.slice(0, LISTED)) lines.push(` ${render(item)}`);
82
84
  if (!verbose && items.length > LISTED) lines.push(` … ${items.length - LISTED} more (--verbose lists all)`);
@@ -27,7 +27,7 @@ export function installStringPacks(win, setups, options = {}) {
27
27
  refused.push(error instanceof Error ? error.message : String(error));
28
28
  }
29
29
  }
30
- const usable = packs.filter(pack => pack.values.size > 0);
30
+ const usable = packs.filter(pack => pack.values.size > 0 || pack.rewrite);
31
31
  return {
32
32
  catalog: setup.adapter.catalog, adapter: setup.adapter, packs, refused, usable,
33
33
  engine: usable.length || options.mark ? createStringEngine(setup.inventory, usable) : null,
@@ -66,7 +66,7 @@ export function installStringPacks(win, setups, options = {}) {
66
66
  continue;
67
67
  const sink = {
68
68
  seen(group = 'main') { state.seen[group] = (state.seen[group] ?? 0) + 1; },
69
- matched(language) { state.languages.add(String(language)); },
69
+ matched(language) { state.languages.add(String(language)); engine.setLanguage(String(language)); },
70
70
  resolve(id, original) {
71
71
  try {
72
72
  state.keys += 1;
@@ -1 +1 @@
1
- {"version":3,"file":"stringPackHook.js","sourceRoot":"","sources":["../../../runtime/src/stringPackHook.ts"],"names":[],"mappings":"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","sourcesContent":["// String packs in the page (published as `waybetter/runtime/stringPackHook`): the per-site\n// \"catalog hook adapter\" interface, the installer that connects adapters to the engine of\n// stringPacks.ts, a self-check that says when a hook did not match, and the generic half of a\n// common hook point (a webpack chunk array).\n//\n// How a site hands its strings to its own code is that site's implementation detail, so the hook\n// is an adapter the app supplies. One site can have several catalogs (different builds of the\n// site, different key spaces); each gets its own adapter, inventory and packs.\n//\n// Everything here fails open: an exception inside a wrapper leaves the site's own behaviour as it\n// was, and with no pack installed nothing is hooked at all.\nimport {\n compilePack, createStringEngine, type CompiledPack, type EngineStats, type PackReport, type PageInventory, type StringEngine,\n type StringInventory,\n} from './stringPacks.ts';\n\n// -------------------------------------------------------------------------------------------\n// The adapter interface\n\n/** What an adapter reports to and asks from the framework. None of these throw. */\nexport type CatalogSink = {\n /** A catalog file of this adapter was seen loading (whether or not the hook then matches). */\n seen(group?: string): void;\n /** The hook matched: the site is about to register values in `language`. */\n matched(language: string, group?: string): void;\n /** The value to register for `id`: a pack's, or exactly `original`. */\n resolve(id: string, original: unknown): unknown;\n};\n\nexport type CatalogHookAdapter = {\n /** The catalog (key space) this adapter hooks; packs and the inventory name the same one. */\n catalog: string;\n /** Installs the hook at document start; returns its removal. Must not change the site's objects. */\n install(win: Window, sink: CatalogSink): () => void;\n};\n\nexport type CatalogSetup = {\n adapter: CatalogHookAdapter;\n inventory: StringInventory | PageInventory;\n /** Pack documents in application order (a later one wins). Untrusted data: validated here. */\n packs: readonly unknown[];\n};\n\n/**\n * `ok`: the hook matched and keys went through it.\n * `not-matched`: a catalog file was seen but the hook did not match it (the site changed).\n * `no-catalog`: no catalog file was seen at all (another build of the site, or not loaded yet).\n */\nexport type HookStatus = 'pending' | 'ok' | 'not-matched' | 'no-catalog';\n\nexport type CatalogReport = {\n catalog: string;\n status: HookStatus;\n /** Languages the site registered through the hook. */\n languages: string[];\n /** Catalog files seen, per group. */\n seen: Record<string, number>;\n packs: { name: string; language: string; report: PackReport }[];\n /** Pack documents that were refused as a whole. */\n refused: string[];\n engine: EngineStats;\n};\n\nexport type StringPacksHandle = {\n status(catalog?: string): HookStatus;\n report(): CatalogReport[];\n /** The engines, per catalog (the originals they keep are what a restore needs). */\n engine(catalog: string): StringEngine | undefined;\n dispose(): void;\n};\n\nexport type StringPacksOptions = {\n /** When the self-check decides (default 15 s after install). */\n settleMs?: number;\n /** Called once per catalog when its status is decided (`ok` as soon as it is reached). */\n onStatus?: (report: CatalogReport) => void;\n /**\n * Translate mode: every value passes through here after the packs (the marker of\n * stringPackTranslate.ts), and the catalog is hooked even without a usable pack.\n */\n mark?: (id: string, value: unknown) => unknown;\n};\n\ntype TimerWindow = { setTimeout(handler: () => void, ms: number): unknown; clearTimeout(id: never): void };\n\n/**\n * Installs the adapters that have at least one usable pack. A catalog without packs is not hooked\n * (the site runs untouched), so its status stays `pending`.\n */\nexport function installStringPacks(win: Window, setups: readonly CatalogSetup[], options: StringPacksOptions = {}): StringPacksHandle {\n const timers = win as unknown as TimerWindow;\n const states = setups.map(setup => {\n const packs: CompiledPack[] = [];\n const refused: string[] = [];\n for (const document of setup.packs) {\n try {\n packs.push(compilePack(document, setup.inventory));\n } catch (error) {\n refused.push(error instanceof Error ? error.message : String(error));\n }\n }\n const usable = packs.filter(pack => pack.values.size > 0);\n return {\n catalog: setup.adapter.catalog, adapter: setup.adapter, packs, refused, usable,\n engine: usable.length || options.mark ? createStringEngine(setup.inventory, usable) : null,\n seen: {} as Record<string, number>, languages: new Set<string>(), keys: 0, settled: false, told: false,\n remove: null as null | (() => void),\n };\n });\n type State = (typeof states)[number];\n\n const status = (state: State): HookStatus => {\n if (state.languages.size && state.keys > 0) return 'ok';\n if (!state.settled) return 'pending';\n return Object.keys(state.seen).length ? 'not-matched' : 'no-catalog';\n };\n const reportOf = (state: State): CatalogReport => ({\n catalog: state.catalog,\n status: status(state),\n languages: [...state.languages],\n seen: { ...state.seen },\n packs: state.packs.map(pack => ({ name: pack.name, language: pack.language, report: pack.report })),\n refused: [...state.refused],\n engine: state.engine?.stats() ?? { seen: 0, applied: 0, unlisted: [], mismatched: [] },\n });\n const tell = (state: State) => {\n if (state.told || status(state) === 'pending') return;\n state.told = true;\n try { options.onStatus?.(reportOf(state)); } catch { /* a reporter must not break the page */ }\n };\n\n for (const state of states) {\n const engine = state.engine;\n if (!engine) continue;\n const sink: CatalogSink = {\n seen(group = 'main') { state.seen[group] = (state.seen[group] ?? 0) + 1; },\n matched(language) { state.languages.add(String(language)); },\n resolve(id, original) {\n try {\n state.keys += 1;\n const resolved = engine.resolve(id, original);\n const value = options.mark ? options.mark(id, resolved) : resolved;\n if (state.keys === 1) tell(state);\n return value;\n } catch {\n return original;\n }\n },\n };\n try {\n state.remove = state.adapter.install(win, sink);\n } catch {\n state.remove = null;\n }\n }\n\n const hooked = states.filter(state => state.engine);\n const timer = hooked.length\n ? timers.setTimeout(() => { for (const state of hooked) { state.settled = true; tell(state); } }, options.settleMs ?? 15000)\n : null;\n\n return {\n status: catalog => {\n const state = catalog ? states.find(item => item.catalog === catalog) : hooked[0] ?? states[0];\n return state ? status(state) : 'pending';\n },\n report: () => states.map(reportOf),\n engine: catalog => states.find(item => item.catalog === catalog)?.engine ?? undefined,\n dispose() {\n if (timer !== null) timers.clearTimeout(timer as never);\n for (const state of states) {\n try { state.remove?.(); } catch { /* the hook is gone either way */ }\n state.remove = null;\n }\n },\n };\n}\n\n// -------------------------------------------------------------------------------------------\n// A webpack chunk array as the hook point\n//\n// A webpack site loads its code as chunks pushed onto a global array\n// (`(globalThis.webpackChunk_x ||= []).push([ids, modules, runtime?])`); webpack's runtime later\n// replaces the array's `push` with its own and replays what is already there. Watching the array\n// therefore sees every chunk before its modules are installed, whichever loads first.\n\ntype Chunk = [unknown, Record<string, unknown> | unknown[], ...unknown[]];\n\n/**\n * Calls `onChunk(chunk, script)` for every chunk pushed onto `win[name]`, before the site's own\n * `push` gets it; `script` is the URL of the script doing the push (`''` when unknown). Install at\n * document start. The array and webpack's own `push` replacement keep working unchanged: the\n * wrapper calls exactly the `push` that was current when it was read, with the same arguments. An\n * exception in `onChunk` is swallowed. Returns the removal.\n */\nexport function watchChunkArray(win: object, name: string, onChunk: (chunk: Chunk, script: string) => void): () => void {\n const holder = win as Record<string, unknown> & { document?: { currentScript?: { src?: string } | null } };\n const existing = holder[name];\n const array: unknown[] = Array.isArray(existing) ? existing : [];\n if (!Array.isArray(existing)) holder[name] = array;\n const done = new WeakSet<object>();\n let current: (...items: unknown[]) => number = array.push;\n let active = true;\n const look = (items: ArrayLike<unknown>) => {\n if (!active) return;\n for (let index = 0; index < items.length; index += 1) {\n const chunk = items[index];\n if (typeof chunk !== 'object' || chunk === null || done.has(chunk)) continue;\n done.add(chunk);\n try {\n onChunk(chunk as Chunk, holder.document?.currentScript?.src ?? '');\n } catch {\n // Fail open: the chunk goes on to the site exactly as it came.\n }\n }\n };\n try {\n Object.defineProperty(array, 'push', {\n configurable: true,\n enumerable: false,\n get() {\n const target = current;\n return function push(this: unknown, ...items: unknown[]) {\n look(items);\n return target.apply(this, items);\n };\n },\n set(next: (...items: unknown[]) => number) { current = next; },\n });\n } catch {\n return () => {};\n }\n return () => {\n active = false;\n try {\n if (current === Array.prototype.push) delete (array as { push?: unknown }).push;\n else Object.defineProperty(array, 'push', { configurable: true, enumerable: false, writable: true, value: current });\n } catch {\n // Left as the pass-through wrapper.\n }\n };\n}\n\n/**\n * Wraps the module factories of one chunk so that what they `require` passes through `map`:\n * `map(exports)` returns a replacement for that one call, or `undefined` to hand out the module's\n * own exports. The module itself is never changed (return a shadow, e.g. `Object.create(exports)`).\n * Only call this for a chunk you have identified; other chunks must stay untouched.\n */\nexport function mapChunkRequires(chunk: Chunk, map: (exports: unknown) => unknown): void {\n const modules = chunk[1];\n if (typeof modules !== 'object' || modules === null) return;\n const table = modules as Record<string, unknown>;\n for (const id of Object.keys(table)) {\n const factory = table[id];\n if (typeof factory !== 'function') continue;\n table[id] = function (this: unknown, module: unknown, exports: unknown, require: unknown, ...rest: unknown[]) {\n let through = require;\n if (typeof require === 'function') {\n try {\n through = new Proxy(require, {\n apply(target, self, args) {\n const result = Reflect.apply(target as (...a: unknown[]) => unknown, self, args);\n try {\n const replaced = map(result);\n return replaced === undefined ? result : replaced;\n } catch {\n return result;\n }\n },\n });\n } catch {\n through = require;\n }\n }\n return (factory as (...a: unknown[]) => unknown).call(this, module, exports, through, ...rest);\n };\n }\n}\n"]}
1
+ {"version":3,"file":"stringPackHook.js","sourceRoot":"","sources":["../../../runtime/src/stringPackHook.ts"],"names":[],"mappings":"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","sourcesContent":["// String packs in the page (published as `waybetter/runtime/stringPackHook`): the per-site\n// \"catalog hook adapter\" interface, the installer that connects adapters to the engine of\n// stringPacks.ts, a self-check that says when a hook did not match, and the generic half of a\n// common hook point (a webpack chunk array).\n//\n// How a site hands its strings to its own code is that site's implementation detail, so the hook\n// is an adapter the app supplies. One site can have several catalogs (different builds of the\n// site, different key spaces); each gets its own adapter, inventory and packs.\n//\n// Everything here fails open: an exception inside a wrapper leaves the site's own behaviour as it\n// was, and with no pack installed nothing is hooked at all.\nimport {\n compilePack, createStringEngine, type CompiledPack, type EngineStats, type PackReport, type PageInventory, type StringEngine,\n type StringInventory,\n} from './stringPacks.ts';\n\n// -------------------------------------------------------------------------------------------\n// The adapter interface\n\n/** What an adapter reports to and asks from the framework. None of these throw. */\nexport type CatalogSink = {\n /** A catalog file of this adapter was seen loading (whether or not the hook then matches). */\n seen(group?: string): void;\n /** The hook matched: the site is about to register values in `language`. */\n matched(language: string, group?: string): void;\n /** The value to register for `id`: a pack's, or exactly `original`. */\n resolve(id: string, original: unknown): unknown;\n};\n\nexport type CatalogHookAdapter = {\n /** The catalog (key space) this adapter hooks; packs and the inventory name the same one. */\n catalog: string;\n /** Installs the hook at document start; returns its removal. Must not change the site's objects. */\n install(win: Window, sink: CatalogSink): () => void;\n};\n\nexport type CatalogSetup = {\n adapter: CatalogHookAdapter;\n inventory: StringInventory | PageInventory;\n /** Pack documents in application order (a later one wins). Untrusted data: validated here. */\n packs: readonly unknown[];\n};\n\n/**\n * `ok`: the hook matched and keys went through it.\n * `not-matched`: a catalog file was seen but the hook did not match it (the site changed).\n * `no-catalog`: no catalog file was seen at all (another build of the site, or not loaded yet).\n */\nexport type HookStatus = 'pending' | 'ok' | 'not-matched' | 'no-catalog';\n\nexport type CatalogReport = {\n catalog: string;\n status: HookStatus;\n /** Languages the site registered through the hook. */\n languages: string[];\n /** Catalog files seen, per group. */\n seen: Record<string, number>;\n packs: { name: string; language: string; report: PackReport }[];\n /** Pack documents that were refused as a whole. */\n refused: string[];\n engine: EngineStats;\n};\n\nexport type StringPacksHandle = {\n status(catalog?: string): HookStatus;\n report(): CatalogReport[];\n /** The engines, per catalog (the originals they keep are what a restore needs). */\n engine(catalog: string): StringEngine | undefined;\n dispose(): void;\n};\n\nexport type StringPacksOptions = {\n /** When the self-check decides (default 15 s after install). */\n settleMs?: number;\n /** Called once per catalog when its status is decided (`ok` as soon as it is reached). */\n onStatus?: (report: CatalogReport) => void;\n /**\n * Translate mode: every value passes through here after the packs (the marker of\n * stringPackTranslate.ts), and the catalog is hooked even without a usable pack.\n */\n mark?: (id: string, value: unknown) => unknown;\n};\n\ntype TimerWindow = { setTimeout(handler: () => void, ms: number): unknown; clearTimeout(id: never): void };\n\n/**\n * Installs the adapters that have at least one usable pack. A catalog without packs is not hooked\n * (the site runs untouched), so its status stays `pending`.\n */\nexport function installStringPacks(win: Window, setups: readonly CatalogSetup[], options: StringPacksOptions = {}): StringPacksHandle {\n const timers = win as unknown as TimerWindow;\n const states = setups.map(setup => {\n const packs: CompiledPack[] = [];\n const refused: string[] = [];\n for (const document of setup.packs) {\n try {\n packs.push(compilePack(document, setup.inventory));\n } catch (error) {\n refused.push(error instanceof Error ? error.message : String(error));\n }\n }\n const usable = packs.filter(pack => pack.values.size > 0 || pack.rewrite);\n return {\n catalog: setup.adapter.catalog, adapter: setup.adapter, packs, refused, usable,\n engine: usable.length || options.mark ? createStringEngine(setup.inventory, usable) : null,\n seen: {} as Record<string, number>, languages: new Set<string>(), keys: 0, settled: false, told: false,\n remove: null as null | (() => void),\n };\n });\n type State = (typeof states)[number];\n\n const status = (state: State): HookStatus => {\n if (state.languages.size && state.keys > 0) return 'ok';\n if (!state.settled) return 'pending';\n return Object.keys(state.seen).length ? 'not-matched' : 'no-catalog';\n };\n const reportOf = (state: State): CatalogReport => ({\n catalog: state.catalog,\n status: status(state),\n languages: [...state.languages],\n seen: { ...state.seen },\n packs: state.packs.map(pack => ({ name: pack.name, language: pack.language, report: pack.report })),\n refused: [...state.refused],\n engine: state.engine?.stats() ?? { seen: 0, applied: 0, unlisted: [], mismatched: [] },\n });\n const tell = (state: State) => {\n if (state.told || status(state) === 'pending') return;\n state.told = true;\n try { options.onStatus?.(reportOf(state)); } catch { /* a reporter must not break the page */ }\n };\n\n for (const state of states) {\n const engine = state.engine;\n if (!engine) continue;\n const sink: CatalogSink = {\n seen(group = 'main') { state.seen[group] = (state.seen[group] ?? 0) + 1; },\n matched(language) { state.languages.add(String(language)); engine.setLanguage(String(language)); },\n resolve(id, original) {\n try {\n state.keys += 1;\n const resolved = engine.resolve(id, original);\n const value = options.mark ? options.mark(id, resolved) : resolved;\n if (state.keys === 1) tell(state);\n return value;\n } catch {\n return original;\n }\n },\n };\n try {\n state.remove = state.adapter.install(win, sink);\n } catch {\n state.remove = null;\n }\n }\n\n const hooked = states.filter(state => state.engine);\n const timer = hooked.length\n ? timers.setTimeout(() => { for (const state of hooked) { state.settled = true; tell(state); } }, options.settleMs ?? 15000)\n : null;\n\n return {\n status: catalog => {\n const state = catalog ? states.find(item => item.catalog === catalog) : hooked[0] ?? states[0];\n return state ? status(state) : 'pending';\n },\n report: () => states.map(reportOf),\n engine: catalog => states.find(item => item.catalog === catalog)?.engine ?? undefined,\n dispose() {\n if (timer !== null) timers.clearTimeout(timer as never);\n for (const state of states) {\n try { state.remove?.(); } catch { /* the hook is gone either way */ }\n state.remove = null;\n }\n },\n };\n}\n\n// -------------------------------------------------------------------------------------------\n// A webpack chunk array as the hook point\n//\n// A webpack site loads its code as chunks pushed onto a global array\n// (`(globalThis.webpackChunk_x ||= []).push([ids, modules, runtime?])`); webpack's runtime later\n// replaces the array's `push` with its own and replays what is already there. Watching the array\n// therefore sees every chunk before its modules are installed, whichever loads first.\n\ntype Chunk = [unknown, Record<string, unknown> | unknown[], ...unknown[]];\n\n/**\n * Calls `onChunk(chunk, script)` for every chunk pushed onto `win[name]`, before the site's own\n * `push` gets it; `script` is the URL of the script doing the push (`''` when unknown). Install at\n * document start. The array and webpack's own `push` replacement keep working unchanged: the\n * wrapper calls exactly the `push` that was current when it was read, with the same arguments. An\n * exception in `onChunk` is swallowed. Returns the removal.\n */\nexport function watchChunkArray(win: object, name: string, onChunk: (chunk: Chunk, script: string) => void): () => void {\n const holder = win as Record<string, unknown> & { document?: { currentScript?: { src?: string } | null } };\n const existing = holder[name];\n const array: unknown[] = Array.isArray(existing) ? existing : [];\n if (!Array.isArray(existing)) holder[name] = array;\n const done = new WeakSet<object>();\n let current: (...items: unknown[]) => number = array.push;\n let active = true;\n const look = (items: ArrayLike<unknown>) => {\n if (!active) return;\n for (let index = 0; index < items.length; index += 1) {\n const chunk = items[index];\n if (typeof chunk !== 'object' || chunk === null || done.has(chunk)) continue;\n done.add(chunk);\n try {\n onChunk(chunk as Chunk, holder.document?.currentScript?.src ?? '');\n } catch {\n // Fail open: the chunk goes on to the site exactly as it came.\n }\n }\n };\n try {\n Object.defineProperty(array, 'push', {\n configurable: true,\n enumerable: false,\n get() {\n const target = current;\n return function push(this: unknown, ...items: unknown[]) {\n look(items);\n return target.apply(this, items);\n };\n },\n set(next: (...items: unknown[]) => number) { current = next; },\n });\n } catch {\n return () => {};\n }\n return () => {\n active = false;\n try {\n if (current === Array.prototype.push) delete (array as { push?: unknown }).push;\n else Object.defineProperty(array, 'push', { configurable: true, enumerable: false, writable: true, value: current });\n } catch {\n // Left as the pass-through wrapper.\n }\n };\n}\n\n/**\n * Wraps the module factories of one chunk so that what they `require` passes through `map`:\n * `map(exports)` returns a replacement for that one call, or `undefined` to hand out the module's\n * own exports. The module itself is never changed (return a shadow, e.g. `Object.create(exports)`).\n * Only call this for a chunk you have identified; other chunks must stay untouched.\n */\nexport function mapChunkRequires(chunk: Chunk, map: (exports: unknown) => unknown): void {\n const modules = chunk[1];\n if (typeof modules !== 'object' || modules === null) return;\n const table = modules as Record<string, unknown>;\n for (const id of Object.keys(table)) {\n const factory = table[id];\n if (typeof factory !== 'function') continue;\n table[id] = function (this: unknown, module: unknown, exports: unknown, require: unknown, ...rest: unknown[]) {\n let through = require;\n if (typeof require === 'function') {\n try {\n through = new Proxy(require, {\n apply(target, self, args) {\n const result = Reflect.apply(target as (...a: unknown[]) => unknown, self, args);\n try {\n const replaced = map(result);\n return replaced === undefined ? result : replaced;\n } catch {\n return result;\n }\n },\n });\n } catch {\n through = require;\n }\n }\n return (factory as (...a: unknown[]) => unknown).call(this, module, exports, through, ...rest);\n };\n }\n}\n"]}
@@ -31,6 +31,7 @@ export type TranslateWords = {
31
31
  key: string;
32
32
  original: string;
33
33
  parameters: string;
34
+ plural: string;
34
35
  save: string;
35
36
  cancel: string;
36
37
  remove: string;
@@ -44,7 +45,8 @@ export type TranslateUiOptions = {
44
45
  marker: Marker;
45
46
  site: string;
46
47
  catalog: string;
47
- words?: Partial<TranslateWords>;
48
+ /** The bar's and the editor's words; a function is asked again each time they are drawn (the app's language may change). */
49
+ words?: Partial<TranslateWords> | (() => Partial<TranslateWords>);
48
50
  /** Leaves translate mode (the app drops the flag and reloads). */
49
51
  exit(): void;
50
52
  };
@@ -85,7 +85,7 @@ export function checkEdit(lookup, id, value) {
85
85
  export const TRANSLATE_WORDS = {
86
86
  bar: 'Translate mode', hint: 'Click a word to change it.', edits: count => `${count} ${count === 1 ? 'edit' : 'edits'}`,
87
87
  exportPack: 'Copy pack file', copied: 'Pack file copied', exit: 'Leave', reload: 'Reload to see it',
88
- key: 'Key', original: 'Now', parameters: 'Parameters', save: 'Save', cancel: 'Cancel', remove: 'Remove edit',
88
+ key: 'Key', original: 'Now', parameters: 'Parameters', plural: 'plural', save: 'Save', cancel: 'Cancel', remove: 'Remove edit',
89
89
  notTranslatable: 'This string cannot be changed by a pack yet.', choose: 'Which one?',
90
90
  };
91
91
  const CSS = `
@@ -108,7 +108,7 @@ button.primary { background: #2563eb; }
108
108
  */
109
109
  export function installTranslateUi(win, options) {
110
110
  const doc = win.document;
111
- const words = { ...TRANSLATE_WORDS, ...options.words };
111
+ const currentWords = () => ({ ...TRANSLATE_WORDS, ...(typeof options.words === 'function' ? options.words() : options.words) });
112
112
  const lookup = inventoryLookup(options.inventory);
113
113
  const storage = win.sessionStorage;
114
114
  const draft = () => readDraft(storage) ?? { language: '', name: '', strings: {} };
@@ -135,6 +135,7 @@ export function installTranslateUi(win, options) {
135
135
  return el;
136
136
  };
137
137
  const renderBar = () => {
138
+ const words = currentWords();
138
139
  bar.replaceChildren();
139
140
  const title = doc.createElement('b');
140
141
  title.textContent = words.bar;
@@ -151,6 +152,7 @@ export function installTranslateUi(win, options) {
151
152
  };
152
153
  const closePop = () => { pop?.remove(); pop = null; };
153
154
  const edit = (id, x, y, now) => {
155
+ const words = currentWords();
154
156
  closePop();
155
157
  pop = doc.createElement('div');
156
158
  pop.className = 'pop';
@@ -174,7 +176,7 @@ export function installTranslateUi(win, options) {
174
176
  }
175
177
  if (shape.params?.length) {
176
178
  const params = doc.createElement('small');
177
- params.textContent = `${words.parameters}: ${shape.params.map(name => `{${name}}${shape.plural?.includes(name) ? ' (plural)' : ''}`).join(', ')}`;
179
+ params.textContent = `${words.parameters}: ${shape.params.map(name => `{${name}}${shape.plural?.includes(name) ? ` (${words.plural})` : ''}`).join(', ')}`;
178
180
  pop.append(params);
179
181
  }
180
182
  const input = doc.createElement('textarea');
@@ -215,6 +217,7 @@ export function installTranslateUi(win, options) {
215
217
  return [];
216
218
  };
217
219
  const onClick = (event) => {
220
+ const words = currentWords();
218
221
  if (event.composedPath().includes(host))
219
222
  return;
220
223
  const found = keysAt(event.target);