@colixsystems/widget-sdk 0.69.0 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -1
- package/dist/contract.cjs +36 -2
- package/dist/contract.js +36 -2
- package/dist/hooks.js +216 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
30
30
|
| **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` (falls back to a CustomEvent / console) — no scope |
|
|
31
31
|
| **CORE** | `useGeolocation(options?)` | `{ latitude, longitude, accuracy, loading, error, getCurrentPosition }` | `ctx.device.geolocation` — no scope. Capture is IMPERATIVE: call `getCurrentPosition()` from a user gesture (a tap), never on mount. Resolves to `{ latitude, longitude, accuracy }`; rejects with `GeolocationError` (`.code` in `PERMISSION_DENIED \| UNAVAILABLE \| TIMEOUT \| UNSUPPORTED \| INTERNAL`). Identical on web (`navigator.geolocation`) and the Expo export (`expo-location`). |
|
|
32
32
|
| **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
|
|
33
|
+
| **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
|
|
33
34
|
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options?)` | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
|
|
34
35
|
| **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
|
|
35
36
|
| **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
|
|
@@ -53,7 +54,39 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
53
54
|
|
|
54
55
|
## Status
|
|
55
56
|
|
|
56
|
-
`v0.
|
|
57
|
+
`v0.70.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
58
|
+
|
|
59
|
+
### What's new in 0.70.0
|
|
60
|
+
|
|
61
|
+
**Translate content the user typed — `useTranslate()` (sc-3783).** The workspace dictionary only covers strings *you* authored; content living in the app's data — a record's description, a file name, a REST payload — has no translation key because nobody assigned it one, so an app user who picked English still read it in whatever language it was entered. `useTranslate()` closes that gap:
|
|
62
|
+
|
|
63
|
+
```jsx
|
|
64
|
+
import { useState, useEffect } from "react";
|
|
65
|
+
import { Text, useTranslate } from "@colixsystems/widget-sdk";
|
|
66
|
+
|
|
67
|
+
const { translate, available, language } = useTranslate();
|
|
68
|
+
const [shown, setShown] = useState(rows.map((r) => r.notes));
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!available) return;
|
|
71
|
+
let live = true;
|
|
72
|
+
// ONE request for the whole batch. On failure keep the original text.
|
|
73
|
+
translate(rows.map((r) => r.notes))
|
|
74
|
+
.then((out) => { if (live) setShown(out); })
|
|
75
|
+
.catch(() => {});
|
|
76
|
+
return () => { live = false; };
|
|
77
|
+
// `language` is in the deps on purpose: the app user can switch language
|
|
78
|
+
// at any time, and this must re-translate when they do.
|
|
79
|
+
}, [rows, available, translate, language]);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- **The target language is the app user's selected language** by default — that is the point of the hook, so a widget never has to know how the language was chosen. Pass `{ target }` only when the widget's purpose is translating into a language the user picks, and `{ source }` when you know the content's language (otherwise the provider auto-detects).
|
|
83
|
+
- **A string in, a string out; an array in, an array out** — positionally aligned, and an array is ONE request. Calling it per row is the mistake to avoid. Limits per call: 50 segments, 5 000 characters each, 20 000 total.
|
|
84
|
+
- **Repeat text is free.** Three caches sit behind it: the hook memoizes per session, the API keeps a short-lived in-process cache, and the platform keeps a durable per-workspace cache keyed by the content itself. Text already in the target language and blank text never reach the network at all.
|
|
85
|
+
- **It costs a metered budget, so use it deliberately.** Each workspace has a monthly translated-character cap (only cache misses count). Exhausting it rejects with `TranslateError` code `TRANSLATION_QUOTA_EXCEEDED`, which — unlike a rate limit — will not clear until the next period; cached translations keep working. `TRANSLATE_NOT_CONFIGURED` means the platform has no provider at all.
|
|
86
|
+
- **Never block a render on it.** Show the original text and swap in the translation when it resolves; always `catch` and fall back. `available` is `false` on a host that brokers no translation client (the Studio canvas preview), where `translate` rejects `UNSUPPORTED` instead of throwing at render — so hide any translate affordance when it is false.
|
|
87
|
+
- **Identical on both hosts.** The web Player and the exported Expo app inject the same new `@colixsystems/translation-client` into `ctx.i18n.translate`, so the hook behaves the same in the browser and on a device.
|
|
88
|
+
|
|
89
|
+
`CONTRACT.version` → `1.47.0`. Additive: one new hook + its error class, and one optional `ctx.i18n.translate` slice field. No existing export changed signature.
|
|
57
90
|
|
|
58
91
|
### What's new in 0.67.0
|
|
59
92
|
|
|
@@ -523,6 +556,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
523
556
|
- **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
|
|
524
557
|
- **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the native-safe way to go multi-column (widgets have no breakpoint hook, so never hard-code fixed columns). Keep wide fields (email, address, notes) full-width, cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only).
|
|
525
558
|
- **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
|
|
559
|
+
- **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`.
|
|
526
560
|
- **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
|
|
527
561
|
- **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useAsset()`. Give every image a sized, `radii`-clipped container so it never renders as a raw rectangle, and never hardcode a credentialed image URL — expose an `image`-type property instead.
|
|
528
562
|
- **Design the empty, loading, and error states.** A blank box on a fresh install reads as broken — show a short helper line when a list is empty, a calm loading line, and a single human sentence in `colors.danger` on error.
|
package/dist/contract.cjs
CHANGED
|
@@ -247,6 +247,31 @@ const HOOKS = [
|
|
|
247
247
|
requiredContextSlice: ["i18n.t", "i18n.locale"],
|
|
248
248
|
scopes: null,
|
|
249
249
|
},
|
|
250
|
+
{
|
|
251
|
+
name: "useTranslate",
|
|
252
|
+
signature: "useTranslate()",
|
|
253
|
+
description:
|
|
254
|
+
"sc-3783 — translate USER-GENERATED content (a record's text, a file name, an API payload) into the app user's selected language. " +
|
|
255
|
+
"NOT for the app's own copy: author-written strings belong in the workspace dictionary and are resolved for free by useI18n().t(key); " +
|
|
256
|
+
"reach for translate() only when there is no key because there is no author. Returns { translate, translating, error, language, available }. " +
|
|
257
|
+
"translate(input, options?) takes a string (resolves to a string) or an array of strings (resolves to an array, positionally aligned) and " +
|
|
258
|
+
"batches an array into ONE request. options.target defaults to the app user's language; options.source is optional (the provider auto-detects). " +
|
|
259
|
+
"Text already in the target language, blank text, and text already translated this session cost nothing and never reach the network. " +
|
|
260
|
+
"Rejects with a TranslateError whose .code is one of UNSUPPORTED | TRANSLATE_NOT_CONFIGURED | TRANSLATION_QUOTA_EXCEEDED | RATE_LIMITED | " +
|
|
261
|
+
"PAYLOAD_TOO_LARGE | VALIDATION | AUTH_REQUIRED | INTERNAL. Limits per call: 50 segments, 5 000 chars each, 20 000 chars total. " +
|
|
262
|
+
"`available` is false on a host that brokers no translation client; translate() then rejects UNSUPPORTED instead of throwing at render. " +
|
|
263
|
+
"Identical on web (Player) and the Expo export — both hosts inject the same @colixsystems/translation-client.",
|
|
264
|
+
returnShape: {
|
|
265
|
+
translate:
|
|
266
|
+
"(input: string | string[], options?: { target?: string, source?: string }) => Promise<string | string[]> // rejects with TranslateError",
|
|
267
|
+
translating: "boolean",
|
|
268
|
+
error: "TranslateError | null",
|
|
269
|
+
language: "string // the app user's selected language (the default target)",
|
|
270
|
+
available: "boolean",
|
|
271
|
+
},
|
|
272
|
+
requiredContextSlice: ["i18n.locale"],
|
|
273
|
+
scopes: null,
|
|
274
|
+
},
|
|
250
275
|
{
|
|
251
276
|
name: "useUser",
|
|
252
277
|
signature: "useUser()",
|
|
@@ -1343,9 +1368,18 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1343
1368
|
// ctx.datastore.records(table).permissions(record). See the `directory`
|
|
1344
1369
|
// and `datastore` slices above.
|
|
1345
1370
|
i18n: {
|
|
1346
|
-
|
|
1371
|
+
// sc-3783 — `translate` is the injected @colixsystems/translation-client
|
|
1372
|
+
// instance backing useTranslate(). OPTIONAL on the slice: a host that
|
|
1373
|
+
// brokers no translation client omits it and the hook reports
|
|
1374
|
+
// available:false (same convention as ctx.toast / ctx.device) rather than
|
|
1375
|
+
// throwing at render. Both the web Player and the Expo export inject it.
|
|
1376
|
+
description:
|
|
1377
|
+
"{ t(key, fallback?), locale, translate? } — `translate` is the injected " +
|
|
1378
|
+
"@colixsystems/translation-client ({ translate(body), status() }) that backs " +
|
|
1379
|
+
"useTranslate() for user-generated content.",
|
|
1347
1380
|
required: true,
|
|
1348
1381
|
fields: { t: "function", locale: "string" },
|
|
1382
|
+
optionalFields: { translate: "object" },
|
|
1349
1383
|
},
|
|
1350
1384
|
logger: {
|
|
1351
1385
|
description:
|
|
@@ -2181,7 +2215,7 @@ const CONTRACT = deepFreeze({
|
|
|
2181
2215
|
// null, which is how one button stays flat while the rest are gradiented.
|
|
2182
2216
|
// Additive: no export changed signature and a theme with no gradient token
|
|
2183
2217
|
// renders exactly as before.
|
|
2184
|
-
version: "1.
|
|
2218
|
+
version: "1.47.0",
|
|
2185
2219
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2186
2220
|
hooks: HOOKS,
|
|
2187
2221
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -247,6 +247,31 @@ const HOOKS = [
|
|
|
247
247
|
requiredContextSlice: ["i18n.t", "i18n.locale"],
|
|
248
248
|
scopes: null,
|
|
249
249
|
},
|
|
250
|
+
{
|
|
251
|
+
name: "useTranslate",
|
|
252
|
+
signature: "useTranslate()",
|
|
253
|
+
description:
|
|
254
|
+
"sc-3783 — translate USER-GENERATED content (a record's text, a file name, an API payload) into the app user's selected language. " +
|
|
255
|
+
"NOT for the app's own copy: author-written strings belong in the workspace dictionary and are resolved for free by useI18n().t(key); " +
|
|
256
|
+
"reach for translate() only when there is no key because there is no author. Returns { translate, translating, error, language, available }. " +
|
|
257
|
+
"translate(input, options?) takes a string (resolves to a string) or an array of strings (resolves to an array, positionally aligned) and " +
|
|
258
|
+
"batches an array into ONE request. options.target defaults to the app user's language; options.source is optional (the provider auto-detects). " +
|
|
259
|
+
"Text already in the target language, blank text, and text already translated this session cost nothing and never reach the network. " +
|
|
260
|
+
"Rejects with a TranslateError whose .code is one of UNSUPPORTED | TRANSLATE_NOT_CONFIGURED | TRANSLATION_QUOTA_EXCEEDED | RATE_LIMITED | " +
|
|
261
|
+
"PAYLOAD_TOO_LARGE | VALIDATION | AUTH_REQUIRED | INTERNAL. Limits per call: 50 segments, 5 000 chars each, 20 000 chars total. " +
|
|
262
|
+
"`available` is false on a host that brokers no translation client; translate() then rejects UNSUPPORTED instead of throwing at render. " +
|
|
263
|
+
"Identical on web (Player) and the Expo export — both hosts inject the same @colixsystems/translation-client.",
|
|
264
|
+
returnShape: {
|
|
265
|
+
translate:
|
|
266
|
+
"(input: string | string[], options?: { target?: string, source?: string }) => Promise<string | string[]> // rejects with TranslateError",
|
|
267
|
+
translating: "boolean",
|
|
268
|
+
error: "TranslateError | null",
|
|
269
|
+
language: "string // the app user's selected language (the default target)",
|
|
270
|
+
available: "boolean",
|
|
271
|
+
},
|
|
272
|
+
requiredContextSlice: ["i18n.locale"],
|
|
273
|
+
scopes: null,
|
|
274
|
+
},
|
|
250
275
|
{
|
|
251
276
|
name: "useUser",
|
|
252
277
|
signature: "useUser()",
|
|
@@ -1343,9 +1368,18 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1343
1368
|
// ctx.datastore.records(table).permissions(record). See the `directory`
|
|
1344
1369
|
// and `datastore` slices above.
|
|
1345
1370
|
i18n: {
|
|
1346
|
-
|
|
1371
|
+
// sc-3783 — `translate` is the injected @colixsystems/translation-client
|
|
1372
|
+
// instance backing useTranslate(). OPTIONAL on the slice: a host that
|
|
1373
|
+
// brokers no translation client omits it and the hook reports
|
|
1374
|
+
// available:false (same convention as ctx.toast / ctx.device) rather than
|
|
1375
|
+
// throwing at render. Both the web Player and the Expo export inject it.
|
|
1376
|
+
description:
|
|
1377
|
+
"{ t(key, fallback?), locale, translate? } — `translate` is the injected " +
|
|
1378
|
+
"@colixsystems/translation-client ({ translate(body), status() }) that backs " +
|
|
1379
|
+
"useTranslate() for user-generated content.",
|
|
1347
1380
|
required: true,
|
|
1348
1381
|
fields: { t: "function", locale: "string" },
|
|
1382
|
+
optionalFields: { translate: "object" },
|
|
1349
1383
|
},
|
|
1350
1384
|
logger: {
|
|
1351
1385
|
description:
|
|
@@ -2181,7 +2215,7 @@ const CONTRACT = deepFreeze({
|
|
|
2181
2215
|
// null, which is how one button stays flat while the rest are gradiented.
|
|
2182
2216
|
// Additive: no export changed signature and a theme with no gradient token
|
|
2183
2217
|
// renders exactly as before.
|
|
2184
|
-
version: "1.
|
|
2218
|
+
version: "1.47.0",
|
|
2185
2219
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2186
2220
|
hooks: HOOKS,
|
|
2187
2221
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -368,6 +368,222 @@ export function useI18n() {
|
|
|
368
368
|
return { t, locale };
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
/**
|
|
372
|
+
* sc-3783 — structured error thrown by `useTranslate().translate`. Carries a
|
|
373
|
+
* stable `code` so widgets branch without parsing message strings; mirrors the
|
|
374
|
+
* shape of `NotificationError` / `DirectoryError`.
|
|
375
|
+
*
|
|
376
|
+
* `code` is one of:
|
|
377
|
+
* - "UNSUPPORTED" — the host brokers no translation client
|
|
378
|
+
* - "TRANSLATE_NOT_CONFIGURED" — 503 (platform has no provider configured)
|
|
379
|
+
* - "TRANSLATION_QUOTA_EXCEEDED" — 429 (workspace's monthly character cap;
|
|
380
|
+
* does NOT clear until the next period)
|
|
381
|
+
* - "RATE_LIMITED" — 429 (too many calls — retry shortly)
|
|
382
|
+
* - "PAYLOAD_TOO_LARGE" — 413 (batch smaller)
|
|
383
|
+
* - "VALIDATION" — 400 (bad segments or language code)
|
|
384
|
+
* - "AUTH_REQUIRED" — 401 (no signed-in app user)
|
|
385
|
+
* - "INTERNAL" — anything else (network, 5xx)
|
|
386
|
+
*/
|
|
387
|
+
export class TranslateError extends Error {
|
|
388
|
+
constructor(code, message, opts) {
|
|
389
|
+
super(message);
|
|
390
|
+
this.name = "TranslateError";
|
|
391
|
+
this.code = code;
|
|
392
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function toTranslateError(err) {
|
|
397
|
+
if (err instanceof TranslateError) return err;
|
|
398
|
+
const status =
|
|
399
|
+
err && err.response && typeof err.response.status === "number"
|
|
400
|
+
? err.response.status
|
|
401
|
+
: err && typeof err.status === "number"
|
|
402
|
+
? err.status
|
|
403
|
+
: null;
|
|
404
|
+
const bodyCode =
|
|
405
|
+
(err && err.response && err.response.data && err.response.data.code) ||
|
|
406
|
+
(err && typeof err.code === "string" ? err.code : null);
|
|
407
|
+
const bodyMessage =
|
|
408
|
+
err && err.response && err.response.data && err.response.data.error;
|
|
409
|
+
let code = "INTERNAL";
|
|
410
|
+
if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
|
|
411
|
+
else if (status === 401) code = "AUTH_REQUIRED";
|
|
412
|
+
else if (status === 413) code = "PAYLOAD_TOO_LARGE";
|
|
413
|
+
else if (status === 429) code = "RATE_LIMITED";
|
|
414
|
+
else if (status === 400) code = "VALIDATION";
|
|
415
|
+
else if (status === 503) code = "TRANSLATE_NOT_CONFIGURED";
|
|
416
|
+
const message =
|
|
417
|
+
(typeof bodyMessage === "string" && bodyMessage) ||
|
|
418
|
+
(err && typeof err.message === "string" ? err.message : "Translation failed");
|
|
419
|
+
return new TranslateError(code, message, { cause: err });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Session-lifetime memo shared by every widget instance on the page. This is
|
|
423
|
+
// the third cache layer (the API keeps an in-process one, the DB keeps the
|
|
424
|
+
// durable one) and the only one that avoids the network entirely — it matters
|
|
425
|
+
// because a list widget re-renders constantly with the same row text. Keyed by
|
|
426
|
+
// the same tuple the server keys by, so it can never answer for the wrong pair.
|
|
427
|
+
const TRANSLATE_MEMO_MAX = 1000;
|
|
428
|
+
const translateMemo = new Map();
|
|
429
|
+
|
|
430
|
+
function memoKey(text, source, target) {
|
|
431
|
+
return `${target}|${source || "auto"}|${text}`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function memoRead(text, source, target) {
|
|
435
|
+
const key = memoKey(text, source, target);
|
|
436
|
+
if (!translateMemo.has(key)) return undefined;
|
|
437
|
+
const value = translateMemo.get(key);
|
|
438
|
+
translateMemo.delete(key);
|
|
439
|
+
translateMemo.set(key, value);
|
|
440
|
+
return value;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function memoWrite(text, source, target, value) {
|
|
444
|
+
if (translateMemo.size >= TRANSLATE_MEMO_MAX) {
|
|
445
|
+
const oldest = translateMemo.keys().next();
|
|
446
|
+
if (!oldest.done) translateMemo.delete(oldest.value);
|
|
447
|
+
}
|
|
448
|
+
translateMemo.set(memoKey(text, source, target), value);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Exposed so tests (and a host teardown) can drop the session memo. */
|
|
452
|
+
export function _resetTranslateMemo() {
|
|
453
|
+
translateMemo.clear();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* sc-3783 — translate USER-GENERATED content into the app user's language.
|
|
458
|
+
* Returns `{ translate, translating, error, language, available }`.
|
|
459
|
+
*
|
|
460
|
+
* Use this for text nobody authored: a datastore record's description, a file
|
|
461
|
+
* name, a REST payload. The app's OWN copy belongs in the workspace dictionary
|
|
462
|
+
* and is resolved for free by `useI18n().t(key)` — reach for `translate` only
|
|
463
|
+
* when there is no key because there is no author.
|
|
464
|
+
*
|
|
465
|
+
* const { translate } = useTranslate();
|
|
466
|
+
* const shown = await translate(record.description); // → app user's language
|
|
467
|
+
* const rows = await translate(items.map((i) => i.title)); // batch, one request
|
|
468
|
+
* const de = await translate(text, { source: "sv", target: "de" });
|
|
469
|
+
*
|
|
470
|
+
* `translate` accepts a string (resolves to a string) or an array of strings
|
|
471
|
+
* (resolves to an array, positionally aligned). `target` defaults to the app
|
|
472
|
+
* user's selected language — the whole point of the hook, so a widget never has
|
|
473
|
+
* to know how the language was chosen. `source` is optional; the provider
|
|
474
|
+
* auto-detects when it is omitted.
|
|
475
|
+
*
|
|
476
|
+
* Text already in the target language, blank text, and text this session has
|
|
477
|
+
* already translated cost nothing and never reach the network.
|
|
478
|
+
*
|
|
479
|
+
* `available` is false when the host brokers no translation client (an older
|
|
480
|
+
* host). Calling `translate` then rejects with code "UNSUPPORTED" rather than
|
|
481
|
+
* throwing at render, so a widget degrades to untranslated text instead of
|
|
482
|
+
* breaking the page.
|
|
483
|
+
*/
|
|
484
|
+
export function useTranslate() {
|
|
485
|
+
const ctx = useWidgetContextOrThrow("useTranslate");
|
|
486
|
+
const i18n = ctx.i18n || {};
|
|
487
|
+
const locale = typeof i18n.locale === "string" && i18n.locale ? i18n.locale : "en";
|
|
488
|
+
const client =
|
|
489
|
+
i18n.translate && typeof i18n.translate.translate === "function"
|
|
490
|
+
? i18n.translate
|
|
491
|
+
: null;
|
|
492
|
+
|
|
493
|
+
// `ctx` is a fresh identity each host render — hold the client + locale in
|
|
494
|
+
// refs so the returned callback stays stable across renders.
|
|
495
|
+
const clientRef = useRef(client);
|
|
496
|
+
clientRef.current = client;
|
|
497
|
+
const localeRef = useRef(locale);
|
|
498
|
+
localeRef.current = locale;
|
|
499
|
+
|
|
500
|
+
const [translating, setTranslating] = useState(false);
|
|
501
|
+
const [error, setError] = useState(null);
|
|
502
|
+
|
|
503
|
+
const translate = useCallback(async (input, options) => {
|
|
504
|
+
const wasArray = Array.isArray(input);
|
|
505
|
+
const segments = wasArray ? input : [input];
|
|
506
|
+
for (const segment of segments) {
|
|
507
|
+
if (typeof segment !== "string") {
|
|
508
|
+
throw new TranslateError(
|
|
509
|
+
"VALIDATION",
|
|
510
|
+
"useTranslate: expected a string or an array of strings",
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const target =
|
|
515
|
+
options && typeof options.target === "string" && options.target
|
|
516
|
+
? options.target
|
|
517
|
+
: localeRef.current;
|
|
518
|
+
const source =
|
|
519
|
+
options && typeof options.source === "string" && options.source
|
|
520
|
+
? options.source
|
|
521
|
+
: null;
|
|
522
|
+
|
|
523
|
+
// Nothing to do: same language in and out, so return the input untouched
|
|
524
|
+
// without waking the network or the provider.
|
|
525
|
+
if (source && source === target) return wasArray ? [...segments] : segments[0];
|
|
526
|
+
|
|
527
|
+
const out = new Array(segments.length);
|
|
528
|
+
const missing = [];
|
|
529
|
+
const missingIndices = [];
|
|
530
|
+
for (let i = 0; i < segments.length; i++) {
|
|
531
|
+
const text = segments[i];
|
|
532
|
+
if (text.trim() === "") {
|
|
533
|
+
out[i] = text;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
const memoized = memoRead(text, source, target);
|
|
537
|
+
if (memoized !== undefined) {
|
|
538
|
+
out[i] = memoized;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
missing.push(text);
|
|
542
|
+
missingIndices.push(i);
|
|
543
|
+
}
|
|
544
|
+
if (missing.length === 0) return wasArray ? out : out[0];
|
|
545
|
+
|
|
546
|
+
if (!clientRef.current) {
|
|
547
|
+
const err = new TranslateError(
|
|
548
|
+
"UNSUPPORTED",
|
|
549
|
+
"useTranslate: the host brokers no translation client",
|
|
550
|
+
);
|
|
551
|
+
setError(err);
|
|
552
|
+
throw err;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
setTranslating(true);
|
|
556
|
+
setError(null);
|
|
557
|
+
try {
|
|
558
|
+
const res = await clientRef.current.translate({
|
|
559
|
+
segments: missing,
|
|
560
|
+
target_language: target,
|
|
561
|
+
source_language: source || undefined,
|
|
562
|
+
});
|
|
563
|
+
const translations = Array.isArray(res && res.translations) ? res.translations : [];
|
|
564
|
+
for (let m = 0; m < missingIndices.length; m++) {
|
|
565
|
+
// A short response must not shift results onto the wrong segment; fall
|
|
566
|
+
// back to the source text for anything the server did not answer.
|
|
567
|
+
const value =
|
|
568
|
+
typeof translations[m] === "string" && translations[m].length > 0
|
|
569
|
+
? translations[m]
|
|
570
|
+
: missing[m];
|
|
571
|
+
out[missingIndices[m]] = value;
|
|
572
|
+
memoWrite(missing[m], source, target, value);
|
|
573
|
+
}
|
|
574
|
+
setTranslating(false);
|
|
575
|
+
return wasArray ? out : out[0];
|
|
576
|
+
} catch (err) {
|
|
577
|
+
const e = toTranslateError(err);
|
|
578
|
+
setError(e);
|
|
579
|
+
setTranslating(false);
|
|
580
|
+
throw e;
|
|
581
|
+
}
|
|
582
|
+
}, []);
|
|
583
|
+
|
|
584
|
+
return { translate, translating, error, language: locale, available: client !== null };
|
|
585
|
+
}
|
|
586
|
+
|
|
371
587
|
/* ============================================================================
|
|
372
588
|
* DEVICE — ctx.device (host-brokered device capabilities)
|
|
373
589
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -909,6 +909,39 @@ export function useI18n(): {
|
|
|
909
909
|
t(key: string, fallback?: string): string;
|
|
910
910
|
};
|
|
911
911
|
|
|
912
|
+
/**
|
|
913
|
+
* sc-3783 — translate USER-GENERATED content (record text, file names, API
|
|
914
|
+
* payloads) into the app user's selected language.
|
|
915
|
+
*
|
|
916
|
+
* NOT for the app's own copy: author-written strings belong in the workspace
|
|
917
|
+
* dictionary and are resolved for free by `useI18n().t(key)`. Reach for this
|
|
918
|
+
* only when there is no key because there is no author.
|
|
919
|
+
*
|
|
920
|
+
* A string resolves to a string and an array resolves to an array (positionally
|
|
921
|
+
* aligned, sent as ONE request). `options.target` defaults to the app user's
|
|
922
|
+
* language. Text already in the target language, blank text, and text already
|
|
923
|
+
* translated this session cost nothing and never reach the network.
|
|
924
|
+
*
|
|
925
|
+
* `available` is false on a host that brokers no translation client (the Studio
|
|
926
|
+
* canvas preview); `translate` then rejects with code "UNSUPPORTED" rather than
|
|
927
|
+
* throwing at render, so the widget can show untranslated text.
|
|
928
|
+
*/
|
|
929
|
+
export function useTranslate(): {
|
|
930
|
+
translate(input: string, options?: TranslateOptions): Promise<string>;
|
|
931
|
+
translate(input: string[], options?: TranslateOptions): Promise<string[]>;
|
|
932
|
+
translating: boolean;
|
|
933
|
+
error: TranslateError | null;
|
|
934
|
+
language: string;
|
|
935
|
+
available: boolean;
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
export interface TranslateOptions {
|
|
939
|
+
/** Target language code. Defaults to the app user's selected language. */
|
|
940
|
+
target?: string;
|
|
941
|
+
/** Source language code. Omit to let the provider auto-detect. */
|
|
942
|
+
source?: string;
|
|
943
|
+
}
|
|
944
|
+
|
|
912
945
|
/**
|
|
913
946
|
* The active end-user identity. `id` is null for anonymous visitors and on
|
|
914
947
|
* the Studio canvas preview; every field is guaranteed present (the host
|
|
@@ -1079,6 +1112,29 @@ export class DirectoryError extends Error {
|
|
|
1079
1112
|
);
|
|
1080
1113
|
}
|
|
1081
1114
|
|
|
1115
|
+
/**
|
|
1116
|
+
* sc-3783 — error class thrown by `useTranslate().translate`. The `code` is a
|
|
1117
|
+
* stable categorisation widgets can branch on. TRANSLATION_QUOTA_EXCEEDED and
|
|
1118
|
+
* TRANSLATE_NOT_CONFIGURED will not clear on a retry — show the original text.
|
|
1119
|
+
*/
|
|
1120
|
+
export class TranslateError extends Error {
|
|
1121
|
+
code:
|
|
1122
|
+
| "UNSUPPORTED"
|
|
1123
|
+
| "TRANSLATE_NOT_CONFIGURED"
|
|
1124
|
+
| "TRANSLATION_QUOTA_EXCEEDED"
|
|
1125
|
+
| "RATE_LIMITED"
|
|
1126
|
+
| "PAYLOAD_TOO_LARGE"
|
|
1127
|
+
| "VALIDATION"
|
|
1128
|
+
| "AUTH_REQUIRED"
|
|
1129
|
+
| "INTERNAL"
|
|
1130
|
+
| string;
|
|
1131
|
+
constructor(
|
|
1132
|
+
code: TranslateError["code"],
|
|
1133
|
+
message?: string,
|
|
1134
|
+
opts?: { cause?: unknown },
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1082
1138
|
/**
|
|
1083
1139
|
* sc-890 — error class thrown by `useSendNotification().send`. The `code` is a
|
|
1084
1140
|
* stable categorisation widgets can branch on.
|
package/dist/index.js
CHANGED
package/dist/index.native.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
],
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "node scripts/build.js",
|
|
51
|
-
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
|
|
51
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=18"
|