@thekatze/locolize-core 0.0.0-stage → 0.1.0-test.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 (42) hide show
  1. package/dist/adapters/custom.d.ts +15 -0
  2. package/dist/adapters/custom.js +18 -0
  3. package/dist/adapters/formatjs.d.ts +15 -0
  4. package/dist/adapters/formatjs.js +45 -0
  5. package/dist/adapters/i18next.d.ts +11 -0
  6. package/dist/adapters/i18next.js +26 -0
  7. package/dist/adapters/messages.d.ts +50 -0
  8. package/dist/adapters/messages.js +35 -0
  9. package/dist/adapters/paraglide.d.ts +16 -0
  10. package/dist/adapters/paraglide.js +15 -0
  11. package/dist/create.d.ts +15 -0
  12. package/dist/create.js +17 -0
  13. package/dist/guards.d.ts +6 -0
  14. package/dist/guards.js +11 -0
  15. package/dist/index.d.ts +5 -0
  16. package/dist/index.js +1 -0
  17. package/dist/inline/editor.d.ts +37 -0
  18. package/dist/inline/editor.js +144 -0
  19. package/dist/inline/entry.d.ts +4 -0
  20. package/dist/inline/entry.js +6 -0
  21. package/dist/inline/oauth.d.ts +20 -0
  22. package/dist/inline/oauth.js +118 -0
  23. package/dist/inline/project.d.ts +11 -0
  24. package/dist/inline/project.js +54 -0
  25. package/dist/inline/runtime.d.ts +51 -0
  26. package/dist/inline/runtime.js +115 -0
  27. package/dist/inline/session.d.ts +17 -0
  28. package/dist/inline/session.js +73 -0
  29. package/dist/inline/variables.d.ts +10 -0
  30. package/dist/inline/variables.js +21 -0
  31. package/dist/internal.d.ts +10 -0
  32. package/dist/internal.js +6 -0
  33. package/dist/runtime-plain.d.ts +4 -0
  34. package/dist/runtime-plain.js +5 -0
  35. package/dist/runtime.d.ts +21 -0
  36. package/dist/runtime.js +21 -0
  37. package/dist/store.d.ts +13 -0
  38. package/dist/store.js +29 -0
  39. package/dist/types.d.ts +47 -0
  40. package/dist/types.js +0 -0
  41. package/package.json +60 -4
  42. package/README.md +0 -3
@@ -0,0 +1,15 @@
1
+ import { Adapter, LocolizeConfig } from "../types.js";
2
+ import { MessageId, MessageSchema, Messages, WeakMessageData, WeakMessageSchema } from "./messages.js";
3
+ //#region src/adapters/custom.d.ts
4
+ export interface CustomAdapterOptions<S extends MessageSchema> extends Pick<LocolizeConfig, 'locales' | 'getLocale' | 'setLocale' | 'formatDraft' | 'variablesIn'> {
5
+ translate: (id: MessageId<S>, data: S[MessageId<S>]) => string;
6
+ /** Like `translate`, in any locale. Lets inline editing show every translation. */
7
+ translateIn?: ((id: MessageId<S>, data: S[MessageId<S>], locale: string) => string) | undefined;
8
+ }
9
+ /**
10
+ * Connects any string-keyed `t(id, data)`. `m.cart_items` becomes a function
11
+ * that calls `translate('cart_items', data)`.
12
+ */
13
+ export declare function custom<S extends MessageSchema>({ translate, translateIn, ...config }: CustomAdapterOptions<S>): Adapter<Messages<S>>;
14
+ //#endregion
15
+ export type { MessageId, MessageSchema, Messages, WeakMessageData, WeakMessageSchema };
@@ -0,0 +1,18 @@
1
+ import { createMessages, resolveId, uncheckedArgs } from "./messages.js";
2
+ //#region src/adapters/custom.ts
3
+ /**
4
+ * Connects any string-keyed `t(id, data)`. `m.cart_items` becomes a function
5
+ * that calls `translate('cart_items', data)`.
6
+ */
7
+ function custom({ translate, translateIn, ...config }) {
8
+ return {
9
+ messages: createMessages(translate),
10
+ config: {
11
+ ...config,
12
+ resolveId,
13
+ renderIn: translateIn ? (message, data, locale) => translateIn(...uncheckedArgs(resolveId(message), data), locale) : void 0
14
+ }
15
+ };
16
+ }
17
+ //#endregion
18
+ export { custom };
@@ -0,0 +1,15 @@
1
+ import { Adapter } from "../types.js";
2
+ import { MessageSchema, Messages, WeakMessageData, WeakMessageSchema } from "./messages.js";
3
+ //#region src/adapters/formatjs.d.ts
4
+ /**
5
+ * Connects ICU messages formatted with FormatJS, the engine behind
6
+ * react-intl. Owns the `IntlShape` per locale; drafts are ICU too, so plurals
7
+ * preview correctly while typing.
8
+ */
9
+ export declare function formatjs<S extends MessageSchema>({ messages, locale: initial }: {
10
+ /** Locale → id → ICU message. */
11
+ messages: Record<string, Record<string, string>>;
12
+ locale: string;
13
+ }): Adapter<Messages<S>>;
14
+ //#endregion
15
+ export type { MessageSchema, Messages, WeakMessageData, WeakMessageSchema };
@@ -0,0 +1,45 @@
1
+ import { isRecord } from "../guards.js";
2
+ import { custom } from "./custom.js";
3
+ import { createIntl } from "@formatjs/intl";
4
+ //#region src/adapters/formatjs.ts
5
+ /** The data's string and number values, the ones ICU placeholders take. */
6
+ function values(data) {
7
+ if (!isRecord(data)) return {};
8
+ return Object.fromEntries(Object.entries(data).filter((entry) => typeof entry[1] === "string" || typeof entry[1] === "number"));
9
+ }
10
+ /**
11
+ * Connects ICU messages formatted with FormatJS, the engine behind
12
+ * react-intl. Owns the `IntlShape` per locale; drafts are ICU too, so plurals
13
+ * preview correctly while typing.
14
+ */
15
+ function formatjs({ messages, locale: initial }) {
16
+ const intls = /* @__PURE__ */ new Map();
17
+ const intlFor = (locale) => {
18
+ let found = intls.get(locale);
19
+ if (!found) {
20
+ found = createIntl({
21
+ locale,
22
+ messages: messages[locale] ?? {}
23
+ });
24
+ intls.set(locale, found);
25
+ }
26
+ return found;
27
+ };
28
+ let intl = intlFor(initial);
29
+ return custom({
30
+ translate: (id, data) => intl.formatMessage({ id }, values(data)),
31
+ translateIn: (id, data, locale) => intlFor(locale).formatMessage({ id }, values(data)),
32
+ formatDraft: (template, data, locale) => createIntl({
33
+ locale,
34
+ messages: { draft: template },
35
+ onError: () => {}
36
+ }).formatMessage({ id: "draft" }, values(data)),
37
+ locales: Object.keys(messages),
38
+ getLocale: () => intl.locale,
39
+ setLocale: (locale) => {
40
+ intl = intlFor(locale);
41
+ }
42
+ });
43
+ }
44
+ //#endregion
45
+ export { formatjs };
@@ -0,0 +1,11 @@
1
+ import { Adapter } from "../types.js";
2
+ import { MessageSchema, Messages, WeakMessageData, WeakMessageSchema } from "./messages.js";
3
+ import { i18n } from "i18next";
4
+ //#region src/adapters/i18next.d.ts
5
+ /**
6
+ * Connects an initialized i18next instance. Locales come from its `resources`
7
+ * (or `supportedLngs`), drafts use its interpolation prefix and suffix.
8
+ */
9
+ export declare function i18next<S extends MessageSchema>(i18n: i18n): Adapter<Messages<S>>;
10
+ //#endregion
11
+ export type { MessageSchema, Messages, WeakMessageData, WeakMessageSchema };
@@ -0,0 +1,26 @@
1
+ import { isRecord } from "../guards.js";
2
+ import { delimited } from "../inline/variables.js";
3
+ import { custom } from "./custom.js";
4
+ //#region src/adapters/i18next.ts
5
+ const options = (data) => isRecord(data) ? data : {};
6
+ /**
7
+ * Connects an initialized i18next instance. Locales come from its `resources`
8
+ * (or `supportedLngs`), drafts use its interpolation prefix and suffix.
9
+ */
10
+ function i18next(i18n) {
11
+ const { resources, supportedLngs, interpolation } = i18n.options;
12
+ const { interpolate, variablesIn } = delimited(interpolation?.prefix ?? "{{", interpolation?.suffix ?? "}}");
13
+ return custom({
14
+ translate: (id, data) => i18n.t(id, options(data)),
15
+ translateIn: (id, data, locale) => i18n.getFixedT(locale)(id, options(data)),
16
+ locales: resources ? Object.keys(resources) : supportedLngs === false || supportedLngs === void 0 ? [] : supportedLngs.filter((locale) => locale !== "cimode"),
17
+ getLocale: () => i18n.language,
18
+ setLocale: async (locale) => {
19
+ await i18n.changeLanguage(locale);
20
+ },
21
+ formatDraft: interpolate,
22
+ variablesIn
23
+ });
24
+ }
25
+ //#endregion
26
+ export { i18next };
@@ -0,0 +1,50 @@
1
+ import { AnyMessage, MessageArgs } from "../types.js";
2
+ //#region src/adapters/messages.d.ts
3
+ /**
4
+ * Describes a string-keyed library's messages: `{ [id]: data }`.
5
+ * Use `undefined` for messages without parameters.
6
+ *
7
+ * @example
8
+ * type AppMessages = {
9
+ * welcome: undefined;
10
+ * cart_items: { count: number };
11
+ * sign_off: { name?: string }; // all-optional → data is optional too
12
+ * };
13
+ */
14
+ export type MessageSchema = object;
15
+ /** Loose data for messages not (yet) described precisely: optional, any shape. */
16
+ export type WeakMessageData = Record<string, unknown> | undefined;
17
+ /**
18
+ * For gradual adoption: every message accepts optional, untyped data.
19
+ * Pass your keys to keep key checking, or omit them to allow any key.
20
+ * Intersect with a precise schema to tighten messages one by one.
21
+ *
22
+ * @example
23
+ * type AppMessages = WeakMessageSchema<keyof typeof en> & {
24
+ * cart_items: { count: number };
25
+ * };
26
+ */
27
+ export type WeakMessageSchema<K extends string = string> = Record<K, WeakMessageData>;
28
+ export type MessageId<S extends MessageSchema> = Extract<keyof S, string>;
29
+ declare const MESSAGE_ID: unique symbol;
30
+ export type Messages<S extends MessageSchema> = { readonly [K in MessageId<S>]: ((...args: MessageArgs<S[K]>) => string) & {
31
+ readonly [MESSAGE_ID]: K;
32
+ }; };
33
+ /**
34
+ * Wraps any string-based `t(key, data)` so it can be used like Paraglide's `m`:
35
+ * `m.cart_items` is a function that calls `translate('cart_items', data)`.
36
+ * Each function is created once per key, so its identity is stable.
37
+ *
38
+ * @example
39
+ * export const m = createMessages<AppMessages>((id, data) => i18next.t(id, data));
40
+ */
41
+ export declare function createMessages<S extends MessageSchema>(translate: (id: MessageId<S>, data: S[MessageId<S>]) => string): Messages<S>;
42
+ /**
43
+ * An id and data as `translate` expects them. Ids come from property names at
44
+ * runtime, while the schema only exists at the type level, so they can't be
45
+ * checked against it, only trusted.
46
+ */
47
+ export declare function uncheckedArgs<S extends MessageSchema>(id: string, data: unknown): [id: MessageId<S>, data: S[MessageId<S>]];
48
+ export declare function resolveId(message: AnyMessage): string;
49
+ //#endregion
50
+ export {};
@@ -0,0 +1,35 @@
1
+ //#region src/adapters/messages.ts
2
+ const MESSAGE_ID = Symbol.for("locolize.message-id");
3
+ /**
4
+ * Wraps any string-based `t(key, data)` so it can be used like Paraglide's `m`:
5
+ * `m.cart_items` is a function that calls `translate('cart_items', data)`.
6
+ * Each function is created once per key, so its identity is stable.
7
+ *
8
+ * @example
9
+ * export const m = createMessages<AppMessages>((id, data) => i18next.t(id, data));
10
+ */
11
+ function createMessages(translate) {
12
+ const cache = /* @__PURE__ */ new Map();
13
+ const messageFor = (key) => {
14
+ let message = cache.get(key);
15
+ if (!message) {
16
+ message = Object.assign((data) => translate(...uncheckedArgs(key, data)), { [MESSAGE_ID]: key });
17
+ cache.set(key, message);
18
+ }
19
+ return message;
20
+ };
21
+ return new Proxy({}, { get: (_target, key) => typeof key === "string" && key !== "then" && !(key in Object.prototype) ? messageFor(key) : void 0 });
22
+ }
23
+ /**
24
+ * An id and data as `translate` expects them. Ids come from property names at
25
+ * runtime, while the schema only exists at the type level, so they can't be
26
+ * checked against it, only trusted.
27
+ */
28
+ function uncheckedArgs(id, data) {
29
+ return [id, data];
30
+ }
31
+ function resolveId(message) {
32
+ return MESSAGE_ID in message && typeof message[MESSAGE_ID] === "string" ? message[MESSAGE_ID] : "";
33
+ }
34
+ //#endregion
35
+ export { createMessages, resolveId, uncheckedArgs };
@@ -0,0 +1,16 @@
1
+ import { Adapter } from "../types.js";
2
+ //#region src/adapters/paraglide.d.ts
3
+ /** The parts of Paraglide's generated `runtime.js` the adapter needs. */
4
+ export interface ParaglideRuntime {
5
+ locales: readonly string[];
6
+ getLocale: () => string;
7
+ setLocale(locale: string, options?: {
8
+ reload?: boolean;
9
+ }): void | Promise<void>;
10
+ }
11
+ export declare function paraglide<M>({ messages, runtime }: {
12
+ /** The generated `m`. */
13
+ messages: M;
14
+ runtime: ParaglideRuntime;
15
+ }): Adapter<M>;
16
+ //#endregion
@@ -0,0 +1,15 @@
1
+ //#region src/adapters/paraglide.ts
2
+ function paraglide({ messages, runtime }) {
3
+ return {
4
+ messages,
5
+ config: {
6
+ resolveId: (message) => message.name,
7
+ locales: runtime.locales,
8
+ getLocale: runtime.getLocale,
9
+ setLocale: (locale) => runtime.setLocale(locale, { reload: false }),
10
+ renderIn: (message, data, locale) => message(data, { locale })
11
+ }
12
+ };
13
+ }
14
+ //#endregion
15
+ export { paraglide };
@@ -0,0 +1,15 @@
1
+ import { Adapter } from "./types.js";
2
+ import { Locolize } from "./runtime.js";
3
+ //#region src/create.d.ts
4
+ /**
5
+ * Builds the runtime for an adapter. Call it once, at module level, and hand
6
+ * `locolize` to your framework's provider.
7
+ *
8
+ * @example
9
+ * export const { m, locolize } = createLocolize(i18next<AppMessages>(i18n));
10
+ */
11
+ export declare function createLocolize<M>(adapter: Adapter<M>): {
12
+ m: M;
13
+ locolize: Locolize;
14
+ };
15
+ //#endregion
package/dist/create.js ADDED
@@ -0,0 +1,17 @@
1
+ import { createRuntime } from "#runtime";
2
+ //#region src/create.ts
3
+ /**
4
+ * Builds the runtime for an adapter. Call it once, at module level, and hand
5
+ * `locolize` to your framework's provider.
6
+ *
7
+ * @example
8
+ * export const { m, locolize } = createLocolize(i18next<AppMessages>(i18n));
9
+ */
10
+ function createLocolize(adapter) {
11
+ return {
12
+ m: adapter.messages,
13
+ locolize: createRuntime(adapter.config)
14
+ };
15
+ }
16
+ //#endregion
17
+ export { createLocolize };
@@ -0,0 +1,6 @@
1
+ //#region src/guards.d.ts
2
+ /** Narrows `unknown` (parsed JSON, message data) to something with fields. */
3
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
4
+ /** A thrown value as an `Error`, since anything can be thrown. */
5
+ export declare function toError(value: unknown): Error;
6
+ //#endregion
package/dist/guards.js ADDED
@@ -0,0 +1,11 @@
1
+ //#region src/guards.ts
2
+ /** Narrows `unknown` (parsed JSON, message data) to something with fields. */
3
+ function isRecord(value) {
4
+ return typeof value === "object" && value !== null;
5
+ }
6
+ /** A thrown value as an `Error`, since anything can be thrown. */
7
+ function toError(value) {
8
+ return value instanceof Error ? value : new Error(String(value));
9
+ }
10
+ //#endregion
11
+ export { isRecord, toError };
@@ -0,0 +1,5 @@
1
+ import { Adapter, AnyMessage, TranslateFn } from "./types.js";
2
+ import { MessageSchema, Messages, WeakMessageData, WeakMessageSchema } from "./adapters/messages.js";
3
+ import { Locolize } from "./runtime.js";
4
+ import { createLocolize } from "./create.js";
5
+ export { type Adapter, type AnyMessage, type Locolize, type MessageSchema, type Messages, type TranslateFn, type WeakMessageData, type WeakMessageSchema, createLocolize };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createLocolize } from "./create.js";
@@ -0,0 +1,37 @@
1
+ import { Connection } from "./oauth.js";
2
+ import { Files } from "./project.js";
3
+ import { Store } from "../store.js";
4
+ import { SessionApi } from "./session.js";
5
+ //#region src/inline/editor.d.ts
6
+ /** Locale → key → edited value. */
7
+ export type Drafts = Record<string, Record<string, string>>;
8
+ export interface Selection {
9
+ key: string;
10
+ /** The text it was opened from; the edit card sits next to it. */
11
+ anchor: Element;
12
+ }
13
+ export interface EditorState {
14
+ editing: boolean;
15
+ files: Files | null;
16
+ loadError: Error | null;
17
+ selected: Selection | null;
18
+ drafts: Drafts;
19
+ submitting: boolean;
20
+ submitError: Error | null;
21
+ submitted: boolean;
22
+ }
23
+ export interface EditorApi {
24
+ store: Store<EditorState>;
25
+ /** Starts loading the files on sign-in. Safe to call more than once. */
26
+ start: () => void;
27
+ setEditing: (editing: boolean) => void;
28
+ select: (selection: Selection | null) => void;
29
+ setDraft: (locale: string, key: string, value: string) => void;
30
+ /** Drops the key's drafts, in one locale or all. */
31
+ revert: (key: string, locale?: string) => void;
32
+ discard: () => void;
33
+ submit: () => void;
34
+ }
35
+ export declare function changeCount(drafts: Drafts): number;
36
+ export declare function createEditor(connection: Connection, session: SessionApi): EditorApi;
37
+ //#endregion
@@ -0,0 +1,144 @@
1
+ import { isRecord, toError } from "../guards.js";
2
+ import { createStore } from "../store.js";
3
+ import { RequestError, fetchFiles, submitFiles } from "./project.js";
4
+ //#region src/inline/editor.ts
5
+ const NO_DRAFTS = {};
6
+ function changeCount(drafts) {
7
+ return Object.values(drafts).reduce((count, edits) => count + Object.keys(edits).length, 0);
8
+ }
9
+ function storageKey(connection) {
10
+ return `locolize:drafts:${connection.clientId}`;
11
+ }
12
+ function readDrafts(connection) {
13
+ try {
14
+ const stored = sessionStorage.getItem(storageKey(connection));
15
+ if (stored === null) return NO_DRAFTS;
16
+ const parsed = JSON.parse(stored);
17
+ return isDrafts(parsed) ? parsed : NO_DRAFTS;
18
+ } catch {
19
+ return NO_DRAFTS;
20
+ }
21
+ }
22
+ function isDrafts(value) {
23
+ return isRecord(value) && Object.values(value).every((edits) => isRecord(edits) && Object.values(edits).every((edit) => typeof edit === "string"));
24
+ }
25
+ function writeDrafts(connection, drafts) {
26
+ try {
27
+ if (Object.keys(drafts).length > 0) sessionStorage.setItem(storageKey(connection), JSON.stringify(drafts));
28
+ else sessionStorage.removeItem(storageKey(connection));
29
+ } catch {}
30
+ }
31
+ function createEditor(connection, session) {
32
+ const store = createStore({
33
+ editing: false,
34
+ files: null,
35
+ loadError: null,
36
+ selected: null,
37
+ drafts: readDrafts(connection),
38
+ submitting: false,
39
+ submitError: null,
40
+ submitted: false
41
+ });
42
+ const update = (patch) => store.set((current) => ({
43
+ ...current,
44
+ ...patch
45
+ }));
46
+ const setDrafts = (change) => {
47
+ const drafts = change(store.get().drafts);
48
+ update({ drafts });
49
+ writeDrafts(connection, drafts);
50
+ };
51
+ const load = async () => {
52
+ try {
53
+ const token = await session.getAccessToken();
54
+ if (token !== null) update({ files: await fetchFiles(connection, token) });
55
+ } catch (error) {
56
+ update({ loadError: toError(error) });
57
+ }
58
+ };
59
+ let signedIn = false;
60
+ const onSession = () => {
61
+ const now = Boolean(session.store.get().session);
62
+ if (now && !signedIn) load();
63
+ signedIn = now;
64
+ };
65
+ let started = false;
66
+ const start = () => {
67
+ if (started) return;
68
+ started = true;
69
+ session.store.subscribe(onSession);
70
+ onSession();
71
+ };
72
+ const submit = async () => {
73
+ const { files, drafts } = store.get();
74
+ const token = await session.getAccessToken();
75
+ if (files === null || token === null) return;
76
+ update({
77
+ submitting: true,
78
+ submitError: null
79
+ });
80
+ try {
81
+ const merged = Object.fromEntries(Object.entries(files).map(([locale, keys]) => [locale, {
82
+ ...keys,
83
+ ...drafts[locale]
84
+ }]));
85
+ await submitFiles(connection, token, merged);
86
+ update({
87
+ files: await fetchFiles(connection, token),
88
+ submitted: true
89
+ });
90
+ setDrafts(() => NO_DRAFTS);
91
+ } catch (error) {
92
+ if (!(error instanceof RequestError && error.status === 409)) {
93
+ update({ submitError: toError(error) });
94
+ return;
95
+ }
96
+ try {
97
+ update({
98
+ files: await fetchFiles(connection, token),
99
+ submitError: /* @__PURE__ */ new Error("Someone else submitted in the meantime. Their changes are loaded now. Check your edits and submit again.")
100
+ });
101
+ } catch (reloadError) {
102
+ update({ submitError: toError(reloadError) });
103
+ }
104
+ } finally {
105
+ update({ submitting: false });
106
+ }
107
+ };
108
+ return {
109
+ store,
110
+ start,
111
+ setEditing: (editing) => update({ editing }),
112
+ select: (selected) => update({ selected }),
113
+ setDraft(locale, key, value) {
114
+ update({ submitted: false });
115
+ const saved = store.get().files?.[locale]?.[key] ?? "";
116
+ setDrafts((current) => {
117
+ const edits = { ...current[locale] };
118
+ if (value === saved) delete edits[key];
119
+ else edits[key] = value;
120
+ const next = {
121
+ ...current,
122
+ [locale]: edits
123
+ };
124
+ if (Object.keys(edits).length === 0) delete next[locale];
125
+ return next;
126
+ });
127
+ },
128
+ revert(key, locale) {
129
+ setDrafts((current) => {
130
+ const next = {};
131
+ for (const [draftLocale, edits] of Object.entries(current)) {
132
+ const { [key]: _, ...rest } = edits;
133
+ const kept = locale !== void 0 && draftLocale !== locale ? edits : rest;
134
+ if (Object.keys(kept).length > 0) next[draftLocale] = kept;
135
+ }
136
+ return next;
137
+ });
138
+ },
139
+ discard: () => setDrafts(() => NO_DRAFTS),
140
+ submit: () => void submit()
141
+ };
142
+ }
143
+ //#endregion
144
+ export { changeCount, createEditor };
@@ -0,0 +1,4 @@
1
+ import { RuntimeFactory } from "../runtime.js";
2
+ //#region src/inline/entry.d.ts
3
+ export declare const createRuntime: RuntimeFactory;
4
+ //#endregion
@@ -0,0 +1,6 @@
1
+ import { createInlineLocolize } from "./runtime.js";
2
+ import { connection } from "virtual:locolize/options";
3
+ //#region src/inline/entry.ts
4
+ const createRuntime = (config) => createInlineLocolize(config, { connection: connection ?? void 0 });
5
+ //#endregion
6
+ export { createRuntime };
@@ -0,0 +1,20 @@
1
+ //#region src/inline/oauth.d.ts
2
+ export interface Connection {
3
+ /** From the project's settings on Locolize. */
4
+ clientId: string;
5
+ /** Where Locolize runs, e.g. `https://app.locolize.com`. */
6
+ url: string;
7
+ }
8
+ export interface Session {
9
+ accessToken: string;
10
+ refreshToken: string;
11
+ /** Epoch ms. */
12
+ expiresAt: number;
13
+ name: string;
14
+ scope: string;
15
+ }
16
+ export declare function refreshSession(connection: Connection, session: Session): Promise<Session>;
17
+ export declare function login(connection: Connection): Promise<Session>;
18
+ /** In the sign-in popup: hands the result to the opener. */
19
+ export declare function completeLoginInPopup(): boolean;
20
+ //#endregion
@@ -0,0 +1,118 @@
1
+ import { isRecord } from "../guards.js";
2
+ //#region src/inline/oauth.ts
3
+ const SCOPE = "translations:write";
4
+ const POPUP_NAME = "locolize-login";
5
+ function base64Url(bytes) {
6
+ return btoa(String.fromCodePoint(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
7
+ }
8
+ function randomString() {
9
+ return base64Url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)));
10
+ }
11
+ async function s256(input) {
12
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
13
+ return base64Url(new Uint8Array(digest));
14
+ }
15
+ function redirectUri() {
16
+ return `${window.location.origin}/`;
17
+ }
18
+ async function requestTokens(url, body) {
19
+ const response = await fetch(new URL("/api/oauth/token", url), {
20
+ method: "POST",
21
+ body: new URLSearchParams(body)
22
+ });
23
+ const json = await response.json();
24
+ if (!response.ok) {
25
+ const reason = isRecord(json) && typeof json["error"] === "string" ? json["error"] : response.status;
26
+ throw new Error(`Token request failed: ${reason}`);
27
+ }
28
+ if (!isRecord(json) || typeof json["access_token"] !== "string" || typeof json["refresh_token"] !== "string" || typeof json["expires_in"] !== "number" || typeof json["name"] !== "string" || typeof json["scope"] !== "string") throw new Error("Token request returned an unexpected response");
29
+ return {
30
+ accessToken: json["access_token"],
31
+ refreshToken: json["refresh_token"],
32
+ expiresAt: Date.now() + json["expires_in"] * 1e3,
33
+ name: json["name"],
34
+ scope: json["scope"]
35
+ };
36
+ }
37
+ function refreshSession(connection, session) {
38
+ return requestTokens(connection.url, {
39
+ grant_type: "refresh_token",
40
+ refresh_token: session.refreshToken,
41
+ client_id: connection.clientId
42
+ });
43
+ }
44
+ function waitForCallback(popup, state) {
45
+ return new Promise((resolve, reject) => {
46
+ const onMessage = (event) => {
47
+ const { data } = event;
48
+ if (event.source !== popup || event.origin !== window.location.origin || !isCallbackMessage(data) || data.state !== state) return;
49
+ cleanup();
50
+ if (data.code === null) reject(/* @__PURE__ */ new Error(`Sign-in failed: ${data.error ?? "unknown error"}`));
51
+ else resolve(data.code);
52
+ };
53
+ const closedPoll = window.setInterval(() => {
54
+ if (!popup.closed) return;
55
+ cleanup();
56
+ reject(/* @__PURE__ */ new Error("Sign-in window was closed"));
57
+ }, 500);
58
+ const cleanup = () => {
59
+ window.removeEventListener("message", onMessage);
60
+ window.clearInterval(closedPoll);
61
+ };
62
+ window.addEventListener("message", onMessage);
63
+ });
64
+ }
65
+ async function login(connection) {
66
+ const popup = window.open("", POPUP_NAME, "popup,width=520,height=720");
67
+ if (!popup) throw new Error("Sign-in window was blocked");
68
+ try {
69
+ const verifier = randomString();
70
+ const state = randomString();
71
+ const authorize = new URL("/oauth/authorize", connection.url);
72
+ authorize.search = new URLSearchParams({
73
+ client_id: connection.clientId,
74
+ redirect_uri: redirectUri(),
75
+ response_type: "code",
76
+ code_challenge: await s256(verifier),
77
+ code_challenge_method: "S256",
78
+ scope: SCOPE,
79
+ state
80
+ }).toString();
81
+ popup.location.href = authorize.href;
82
+ const code = await waitForCallback(popup, state);
83
+ return await requestTokens(connection.url, {
84
+ grant_type: "authorization_code",
85
+ code,
86
+ redirect_uri: redirectUri(),
87
+ client_id: connection.clientId,
88
+ code_verifier: verifier
89
+ });
90
+ } finally {
91
+ popup.close();
92
+ }
93
+ }
94
+ function isCallbackMessage(value) {
95
+ return isRecord(value) && value["type"] === "locolize:oauth-callback" && typeof value["state"] === "string" && (typeof value["code"] === "string" || value["code"] === null) && (typeof value["error"] === "string" || value["error"] === null);
96
+ }
97
+ function isMessageTarget(value) {
98
+ return isRecord(value) && typeof value["postMessage"] === "function";
99
+ }
100
+ const landing = typeof window === "undefined" ? null : new URL(window.location.href);
101
+ /** In the sign-in popup: hands the result to the opener. */
102
+ function completeLoginInPopup() {
103
+ const opener = window.opener;
104
+ const params = landing?.searchParams;
105
+ const state = params?.get("state") ?? null;
106
+ const code = params?.get("code") ?? null;
107
+ const error = params?.get("error") ?? null;
108
+ if (!isMessageTarget(opener) || state === null || code === null && error === null) return false;
109
+ opener.postMessage({
110
+ type: "locolize:oauth-callback",
111
+ state,
112
+ code,
113
+ error
114
+ }, window.location.origin);
115
+ return true;
116
+ }
117
+ //#endregion
118
+ export { completeLoginInPopup, login, refreshSession };
@@ -0,0 +1,11 @@
1
+ import { Connection } from "./oauth.js";
2
+ //#region src/inline/project.d.ts
3
+ /** Locale → key → saved value. */
4
+ export type Files = Record<string, Record<string, unknown>>;
5
+ export declare class RequestError extends Error {
6
+ readonly status: number;
7
+ constructor(status: number, message: string);
8
+ }
9
+ export declare function fetchFiles(connection: Connection, token: string): Promise<Files>;
10
+ export declare function submitFiles(connection: Connection, token: string, files: Files): Promise<void>;
11
+ //#endregion
@@ -0,0 +1,54 @@
1
+ import { isRecord } from "../guards.js";
2
+ //#region src/inline/project.ts
3
+ function uuid(hex) {
4
+ return [
5
+ hex.slice(0, 8),
6
+ hex.slice(8, 12),
7
+ hex.slice(12, 16),
8
+ hex.slice(16, 20),
9
+ hex.slice(20)
10
+ ].join("-");
11
+ }
12
+ function projectUrl({ clientId, url }, path = "") {
13
+ const team = uuid(clientId.slice(0, 32));
14
+ const project = uuid(clientId.slice(32));
15
+ return new URL(`/api/teams/${team}/projects/${project}${path}`, url);
16
+ }
17
+ var RequestError = class extends Error {
18
+ status;
19
+ constructor(status, message) {
20
+ super(message);
21
+ this.status = status;
22
+ this.name = "RequestError";
23
+ }
24
+ };
25
+ async function request(input, token, init = {}) {
26
+ const response = await fetch(input, {
27
+ ...init,
28
+ headers: {
29
+ ...init.body === void 0 ? {} : { "Content-Type": "application/json" },
30
+ Authorization: `Bearer ${token}`
31
+ }
32
+ });
33
+ if (!response.ok) {
34
+ const body = await response.json().catch(() => null);
35
+ throw new RequestError(response.status, isRecord(body) && typeof body["message"] === "string" ? body["message"] : `Request failed: ${response.status}`);
36
+ }
37
+ return response.json();
38
+ }
39
+ async function fetchFiles(connection, token) {
40
+ const response = await request(projectUrl(connection, "/provider/translation_files"), token);
41
+ if (!isRecord(response) || !isRecord(response["files"])) throw new Error("Unexpected response for translation files");
42
+ const files = {};
43
+ for (const [locale, result] of Object.entries(response["files"])) if (isRecord(result) && isRecord(result["ok"]) && isRecord(result["ok"]["keys"])) files[locale] = result["ok"]["keys"];
44
+ return files;
45
+ }
46
+ async function submitFiles(connection, token, files) {
47
+ const body = Object.fromEntries(Object.entries(files).map(([locale, keys]) => [locale, { keys }]));
48
+ await request(projectUrl(connection, "/provider/submit_translations"), token, {
49
+ method: "POST",
50
+ body: JSON.stringify(body)
51
+ });
52
+ }
53
+ //#endregion
54
+ export { RequestError, fetchFiles, submitFiles };
@@ -0,0 +1,51 @@
1
+ import { AnyMessage, LocolizeConfig } from "../types.js";
2
+ import { Locolize, Snapshot } from "../runtime.js";
3
+ import { Connection } from "./oauth.js";
4
+ import { SessionApi, SessionState } from "./session.js";
5
+ import { EditorApi, EditorState } from "./editor.js";
6
+ //#region src/inline/runtime.d.ts
7
+ /** What a message was rendered with, so the overlay can show and edit it. */
8
+ export interface MessageEntry {
9
+ message: AnyMessage;
10
+ data: unknown;
11
+ }
12
+ export interface InlineLocolizeOptions {
13
+ /** Enables signing in to Locolize and editing. */
14
+ connection?: Connection | undefined;
15
+ }
16
+ export interface InlineSnapshot extends Snapshot {
17
+ showKeys: boolean;
18
+ session: SessionState | null;
19
+ /** Null without a connection. Editing and selection are off while signed out. */
20
+ editor: EditorState | null;
21
+ }
22
+ /**
23
+ * The runtime with everything inline editing needs, without any framework.
24
+ * Its `t()` records the text it returns; the overlay renders its UI from it.
25
+ */
26
+ export interface InlineRuntime extends Locolize<InlineSnapshot> {
27
+ setShowKeys: (showKeys: boolean) => void;
28
+ /** What a message shows, with the current draft if it has one. */
29
+ textOf: (message: AnyMessage, data: unknown, locale?: string) => {
30
+ text: string;
31
+ edited: boolean;
32
+ };
33
+ /** The message in `locale`, or undefined when the library can't tell. */
34
+ renderIn: (message: AnyMessage, data: unknown, locale: string) => string | undefined;
35
+ variablesIn: (template: string) => Set<string>;
36
+ /**
37
+ * Registers the element a message is rendered into. Returns the cleanup, so
38
+ * it fits a React ref callback, a Svelte action or a Vue directive as is.
39
+ */
40
+ track: (element: Element, entry: MessageEntry) => () => void;
41
+ /** Each tracked element on the page, so a hover can find what it shows. */
42
+ rendered: ReadonlyMap<Element, MessageEntry>;
43
+ /** The messages whose `t()` returned this text, by id. */
44
+ entriesForText: (text: string) => ReadonlyMap<string, MessageEntry>;
45
+ session: SessionApi | null;
46
+ editor: EditorApi | null;
47
+ }
48
+ export declare function createInlineLocolize(config: LocolizeConfig, { connection }?: InlineLocolizeOptions): InlineRuntime;
49
+ /** Whether the runtime was built for inline editing. */
50
+ export declare function isInlineRuntime(runtime: Locolize): runtime is InlineRuntime;
51
+ //#endregion
@@ -0,0 +1,115 @@
1
+ import { createStore } from "../store.js";
2
+ import { createEditor } from "./editor.js";
3
+ import { createSession } from "./session.js";
4
+ import { interpolate, variablesIn } from "./variables.js";
5
+ //#region src/inline/runtime.ts
6
+ const NONE = /* @__PURE__ */ new Map();
7
+ function createInlineLocolize(config, { connection } = {}) {
8
+ const session = connection ? createSession(connection) : null;
9
+ const editor = session && connection ? createEditor(connection, session) : null;
10
+ const own = createStore({
11
+ locale: config.getLocale(),
12
+ showKeys: false
13
+ });
14
+ const build = () => {
15
+ const signedIn = Boolean(session?.store.get().session);
16
+ const editorState = editor?.store.get();
17
+ return {
18
+ ...own.get(),
19
+ session: session?.store.get() ?? null,
20
+ editor: editorState ? {
21
+ ...editorState,
22
+ editing: editorState.editing && signedIn,
23
+ selected: signedIn ? editorState.selected : null
24
+ } : null
25
+ };
26
+ };
27
+ let snapshot = build();
28
+ const listeners = /* @__PURE__ */ new Set();
29
+ const onChange = () => {
30
+ snapshot = build();
31
+ for (const listener of Array.from(listeners)) listener();
32
+ };
33
+ own.subscribe(onChange);
34
+ session?.store.subscribe(onChange);
35
+ editor?.store.subscribe(onChange);
36
+ const rendered = /* @__PURE__ */ new Map();
37
+ const registry = /* @__PURE__ */ new Map();
38
+ const textOf = (message, data, locale) => {
39
+ const { showKeys, locale: current } = own.get();
40
+ if (showKeys) return {
41
+ text: config.resolveId(message),
42
+ edited: false
43
+ };
44
+ const target = locale ?? current;
45
+ const id = config.resolveId(message);
46
+ const format = config.formatDraft ?? interpolate;
47
+ const state = editor?.store.get();
48
+ const draft = state?.drafts[target]?.[id];
49
+ if (draft !== void 0) return {
50
+ text: format(draft, data, target),
51
+ edited: true
52
+ };
53
+ const saved = state?.files?.[target]?.[id];
54
+ if (typeof saved === "string") return {
55
+ text: format(saved, data, target),
56
+ edited: false
57
+ };
58
+ return {
59
+ text: (target === current ? message(data) : config.renderIn?.(message, data, target)) ?? "",
60
+ edited: false
61
+ };
62
+ };
63
+ function t(message, data) {
64
+ const { text } = textOf(message, data);
65
+ const entries = registry.get(text) ?? /* @__PURE__ */ new Map();
66
+ entries.set(config.resolveId(message), {
67
+ message,
68
+ data
69
+ });
70
+ registry.set(text, entries);
71
+ return text;
72
+ }
73
+ return {
74
+ config,
75
+ getSnapshot: () => snapshot,
76
+ subscribe(listener) {
77
+ editor?.start();
78
+ listeners.add(listener);
79
+ return () => {
80
+ listeners.delete(listener);
81
+ };
82
+ },
83
+ async setLocale(locale) {
84
+ await config.setLocale(locale);
85
+ own.set((current) => ({
86
+ ...current,
87
+ locale: config.getLocale()
88
+ }));
89
+ },
90
+ setShowKeys: (showKeys) => own.set((current) => ({
91
+ ...current,
92
+ showKeys
93
+ })),
94
+ t,
95
+ textOf,
96
+ renderIn: (message, data, locale) => locale === own.get().locale ? message(data) : config.renderIn?.(message, data, locale),
97
+ variablesIn: config.variablesIn ?? variablesIn,
98
+ track(element, entry) {
99
+ rendered.set(element, entry);
100
+ return () => {
101
+ if (rendered.get(element) === entry) rendered.delete(element);
102
+ };
103
+ },
104
+ rendered,
105
+ entriesForText: (text) => registry.get(text) ?? NONE,
106
+ session,
107
+ editor
108
+ };
109
+ }
110
+ /** Whether the runtime was built for inline editing. */
111
+ function isInlineRuntime(runtime) {
112
+ return "track" in runtime;
113
+ }
114
+ //#endregion
115
+ export { createInlineLocolize, isInlineRuntime };
@@ -0,0 +1,17 @@
1
+ import { Connection, Session } from "./oauth.js";
2
+ import { Store } from "../store.js";
3
+ //#region src/inline/session.d.ts
4
+ export interface SessionState {
5
+ session: Session | null;
6
+ pending: boolean;
7
+ error: Error | null;
8
+ }
9
+ export interface SessionApi {
10
+ store: Store<SessionState>;
11
+ login: () => void;
12
+ logout: () => void;
13
+ /** Refreshed when needed; null when signed out. */
14
+ getAccessToken: () => Promise<string | null>;
15
+ }
16
+ export declare function createSession(connection: Connection): SessionApi;
17
+ //#endregion
@@ -0,0 +1,73 @@
1
+ import { isRecord, toError } from "../guards.js";
2
+ import { createStore } from "../store.js";
3
+ import { completeLoginInPopup, login, refreshSession } from "./oauth.js";
4
+ //#region src/inline/session.ts
5
+ const EXPIRY_MARGIN_MS = 3e4;
6
+ function storageKey(connection) {
7
+ return `locolize:session:${connection.clientId}`;
8
+ }
9
+ function readSession(connection) {
10
+ try {
11
+ const stored = sessionStorage.getItem(storageKey(connection));
12
+ if (stored === null) return null;
13
+ const parsed = JSON.parse(stored);
14
+ return isSession(parsed) ? parsed : null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+ function isSession(value) {
20
+ return isRecord(value) && typeof value["accessToken"] === "string" && typeof value["refreshToken"] === "string" && typeof value["expiresAt"] === "number" && typeof value["name"] === "string" && typeof value["scope"] === "string";
21
+ }
22
+ function writeSession(connection, session) {
23
+ try {
24
+ if (session) sessionStorage.setItem(storageKey(connection), JSON.stringify(session));
25
+ else sessionStorage.removeItem(storageKey(connection));
26
+ } catch {}
27
+ }
28
+ function createSession(connection) {
29
+ if (completeLoginInPopup()) window.close();
30
+ const store = createStore({
31
+ session: readSession(connection),
32
+ pending: false,
33
+ error: null
34
+ });
35
+ let refreshing = null;
36
+ const setSession = (session) => {
37
+ store.set((current) => ({
38
+ ...current,
39
+ session
40
+ }));
41
+ writeSession(connection, session);
42
+ };
43
+ return {
44
+ store,
45
+ login() {
46
+ store.set((current) => ({
47
+ ...current,
48
+ pending: true,
49
+ error: null
50
+ }));
51
+ login(connection).then(setSession).catch((error) => store.set((current) => ({
52
+ ...current,
53
+ error: toError(error)
54
+ }))).finally(() => store.set((current) => ({
55
+ ...current,
56
+ pending: false
57
+ })));
58
+ },
59
+ logout: () => setSession(null),
60
+ async getAccessToken() {
61
+ const current = store.get().session;
62
+ if (!current) return null;
63
+ if (current.expiresAt - EXPIRY_MARGIN_MS > Date.now()) return current.accessToken;
64
+ refreshing ??= refreshSession(connection, current).catch(() => null);
65
+ const next = await refreshing;
66
+ refreshing = null;
67
+ setSession(next);
68
+ return next?.accessToken ?? null;
69
+ }
70
+ };
71
+ }
72
+ //#endregion
73
+ export { createSession };
@@ -0,0 +1,10 @@
1
+ //#region src/inline/variables.d.ts
2
+ /** Filling and finding variables written as `<prefix>name<suffix>`, e.g. `{{name}}`. */
3
+ export declare function delimited(prefix: string, suffix: string): {
4
+ interpolate: (template: string, inputs: unknown) => string;
5
+ variablesIn: (text: string) => Set<string>;
6
+ };
7
+ export declare const interpolate: (template: string, inputs: unknown) => string, variablesIn: (text: string) => Set<string>;
8
+ /** The variables a message was called with, as `{name}` placeholders. */
9
+ export declare function placeholders(inputs: unknown): Record<string, string>;
10
+ //#endregion
@@ -0,0 +1,21 @@
1
+ import { isRecord } from "../guards.js";
2
+ //#region src/inline/variables.ts
3
+ const escape = (text) => text.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
4
+ /** Filling and finding variables written as `<prefix>name<suffix>`, e.g. `{{name}}`. */
5
+ function delimited(prefix, suffix) {
6
+ const variable = new RegExp(`${escape(prefix)}\\s*(\\w+)\\s*${escape(suffix)}`, "gu");
7
+ return {
8
+ interpolate(template, inputs) {
9
+ const values = isRecord(inputs) ? inputs : {};
10
+ return template.replaceAll(variable, (match, name) => name in values ? String(values[name]) : match);
11
+ },
12
+ variablesIn: (text) => new Set(Array.from(text.matchAll(variable), ([, name = ""]) => name))
13
+ };
14
+ }
15
+ const { interpolate, variablesIn } = delimited("{", "}");
16
+ /** The variables a message was called with, as `{name}` placeholders. */
17
+ function placeholders(inputs) {
18
+ return Object.fromEntries(Object.keys(isRecord(inputs) ? inputs : {}).map((name) => [name, `{${name}}`]));
19
+ }
20
+ //#endregion
21
+ export { delimited, interpolate, placeholders, variablesIn };
@@ -0,0 +1,10 @@
1
+ import { Adapter, AnyMessage, IsAny, LocolizeConfig, MessageArgs, MessageData, TranslateFn } from "./types.js";
2
+ import { Locolize, RuntimeFactory, Snapshot, createPlainRuntime } from "./runtime.js";
3
+ import { Connection, Session } from "./inline/oauth.js";
4
+ import { Files } from "./inline/project.js";
5
+ import { Store, createStore } from "./store.js";
6
+ import { SessionApi, SessionState } from "./inline/session.js";
7
+ import { Drafts, EditorApi, EditorState, Selection, changeCount } from "./inline/editor.js";
8
+ import { InlineLocolizeOptions, InlineRuntime, InlineSnapshot, MessageEntry, createInlineLocolize, isInlineRuntime } from "./inline/runtime.js";
9
+ import { delimited, interpolate, placeholders, variablesIn } from "./inline/variables.js";
10
+ export { Adapter, AnyMessage, type Connection, type Drafts, type EditorApi, type EditorState, type Files, InlineLocolizeOptions, InlineRuntime, InlineSnapshot, IsAny, Locolize, LocolizeConfig, MessageArgs, MessageData, MessageEntry, RuntimeFactory, type Selection, type Session, type SessionApi, type SessionState, Snapshot, Store, TranslateFn, changeCount, createInlineLocolize, createPlainRuntime, createStore, delimited, interpolate, isInlineRuntime, placeholders, variablesIn };
@@ -0,0 +1,6 @@
1
+ import { createStore } from "./store.js";
2
+ import { createPlainRuntime } from "./runtime.js";
3
+ import { changeCount } from "./inline/editor.js";
4
+ import { delimited, interpolate, placeholders, variablesIn } from "./inline/variables.js";
5
+ import { createInlineLocolize, isInlineRuntime } from "./inline/runtime.js";
6
+ export { changeCount, createInlineLocolize, createPlainRuntime, createStore, delimited, interpolate, isInlineRuntime, placeholders, variablesIn };
@@ -0,0 +1,4 @@
1
+ import { RuntimeFactory } from "./runtime.js";
2
+ //#region src/runtime-plain.d.ts
3
+ export declare const createRuntime: RuntimeFactory;
4
+ //#endregion
@@ -0,0 +1,5 @@
1
+ import { createPlainRuntime } from "./runtime.js";
2
+ //#region src/runtime-plain.ts
3
+ const createRuntime = createPlainRuntime;
4
+ //#endregion
5
+ export { createRuntime };
@@ -0,0 +1,21 @@
1
+ import { LocolizeConfig, TranslateFn } from "./types.js";
2
+ //#region src/runtime.d.ts
3
+ export interface Snapshot {
4
+ locale: string;
5
+ }
6
+ /**
7
+ * What every framework package needs: the config, the current locale and `t`.
8
+ * They provide it through their own DI and re-render on `subscribe`. Inline
9
+ * editing extends it, so builds without it ship only this.
10
+ */
11
+ export interface Locolize<S extends Snapshot = Snapshot> {
12
+ config: LocolizeConfig;
13
+ getSnapshot: () => S;
14
+ subscribe: (listener: () => void) => () => void;
15
+ setLocale: (locale: string) => Promise<void>;
16
+ t: TranslateFn;
17
+ }
18
+ export declare function createPlainRuntime(config: LocolizeConfig): Locolize;
19
+ /** Builds the runtime; `#runtime` picks the plain or the inline one. */
20
+ export type RuntimeFactory = (config: LocolizeConfig) => Locolize;
21
+ //#endregion
@@ -0,0 +1,21 @@
1
+ import { createStore } from "./store.js";
2
+ //#region src/runtime.ts
3
+ function translate(message, ...args) {
4
+ return message(...args);
5
+ }
6
+ const t = translate;
7
+ function createPlainRuntime(config) {
8
+ const store = createStore({ locale: config.getLocale() });
9
+ return {
10
+ config,
11
+ getSnapshot: store.get,
12
+ subscribe: store.subscribe,
13
+ async setLocale(locale) {
14
+ await config.setLocale(locale);
15
+ store.set({ locale: config.getLocale() });
16
+ },
17
+ t
18
+ };
19
+ }
20
+ //#endregion
21
+ export { createPlainRuntime };
@@ -0,0 +1,13 @@
1
+ //#region src/store.d.ts
2
+ /**
3
+ * A value that tells its listeners when it changes. Follows the contract of
4
+ * React's `useSyncExternalStore`, so every framework can wrap it in its own
5
+ * reactivity: Vue `shallowRef`, Svelte `createSubscriber`, Solid `from()`.
6
+ */
7
+ export interface Store<T> {
8
+ get: () => T;
9
+ set: (next: T | ((current: T) => T)) => void;
10
+ subscribe: (listener: () => void) => () => void;
11
+ }
12
+ export declare function createStore<T>(initial: T): Store<T>;
13
+ //#endregion
package/dist/store.js ADDED
@@ -0,0 +1,29 @@
1
+ //#region src/store.ts
2
+ /**
3
+ * Whether `set` got an updater. Don't store functions in a store: they'd be
4
+ * called as updaters.
5
+ */
6
+ function isUpdater(next) {
7
+ return typeof next === "function";
8
+ }
9
+ function createStore(initial) {
10
+ let value = initial;
11
+ const listeners = /* @__PURE__ */ new Set();
12
+ return {
13
+ get: () => value,
14
+ set(next) {
15
+ const resolved = isUpdater(next) ? next(value) : next;
16
+ if (Object.is(resolved, value)) return;
17
+ value = resolved;
18
+ for (const listener of Array.from(listeners)) listener();
19
+ },
20
+ subscribe(listener) {
21
+ listeners.add(listener);
22
+ return () => {
23
+ listeners.delete(listener);
24
+ };
25
+ }
26
+ };
27
+ }
28
+ //#endregion
29
+ export { createStore };
@@ -0,0 +1,47 @@
1
+ //#region src/types.d.ts
2
+ /**
3
+ * A message is any function that returns its translated string.
4
+ * Paraglide's compiled messages already are one; string-keyed libraries get
5
+ * them from `createMessages()`. Call sites reference them statically
6
+ * (`m.cart_items`), which is what keeps per-message tree-shaking intact.
7
+ */
8
+ export type AnyMessage = (...args: any[]) => string;
9
+ /** The data a message takes, read from its first parameter. `undefined` = none. */
10
+ export type MessageData<F extends AnyMessage> = Parameters<F> extends [] ? undefined : Parameters<F> extends [(infer D)?, ...unknown[]] ? NonNullable<D> extends Record<string, never> ? undefined : D : undefined;
11
+ /** True only for `any`, which would otherwise match every branch below. */
12
+ export type IsAny<T> = 0 extends 1 & T ? true : false;
13
+ /** The arguments after the message: none, optional or required data. */
14
+ export type MessageArgs<D> = IsAny<D> extends true ? [data?: unknown] : [D] extends [undefined] ? [] : undefined extends D ? [data?: Exclude<D, undefined>] : {} extends D ? [data?: D] : [data: D];
15
+ export type TranslateFn = <F extends AnyMessage>(message: F, ...args: MessageArgs<MessageData<F>>) => string;
16
+ export interface LocolizeConfig {
17
+ resolveId: (message: AnyMessage) => string;
18
+ /** Every locale the app can switch to. */
19
+ locales: readonly string[];
20
+ /** The library's current locale. */
21
+ getLocale: () => string;
22
+ /** Switches the library's locale; adapters re-render once it settles. */
23
+ setLocale: (locale: string) => void | Promise<void>;
24
+ /**
25
+ * Renders a message in any locale, not just the current one. Inline editing
26
+ * uses it to list all translations and to hint at missing ones; without it,
27
+ * only the current locale is shown.
28
+ */
29
+ renderIn?: ((message: AnyMessage, data: unknown, locale: string) => string) | undefined;
30
+ /**
31
+ * Fills a draft's variables to preview it in place. Defaults to replacing
32
+ * `{name}` placeholders; libraries with other syntax (`{{name}}`, ICU) pass
33
+ * their own.
34
+ */
35
+ formatDraft?: ((template: string, data: unknown, locale: string) => string) | undefined;
36
+ /** The variable names a draft uses, to warn about missing ones. Defaults to `{name}`. */
37
+ variablesIn?: ((template: string) => Set<string>) | undefined;
38
+ }
39
+ /**
40
+ * Connects an i18n library: its messages and how Locolize drives it. Made by
41
+ * the functions in `@thekatze/locolize-core/adapters/*`.
42
+ */
43
+ export interface Adapter<M> {
44
+ messages: M;
45
+ config: LocolizeConfig;
46
+ }
47
+ //#endregion
package/dist/types.js ADDED
File without changes
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@thekatze/locolize-core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-test.0",
4
+ "description": "Locolize runtime and i18n library adapters (Paraglide, i18next, FormatJS, custom).",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/enXess-ag/locolize-js.git",
8
+ "directory": "packages/core"
9
+ },
10
+ "type": "module",
11
+ "sideEffects": false,
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./adapters/custom": {
21
+ "types": "./dist/adapters/custom.d.ts",
22
+ "default": "./dist/adapters/custom.js"
23
+ },
24
+ "./adapters/paraglide": {
25
+ "types": "./dist/adapters/paraglide.d.ts",
26
+ "default": "./dist/adapters/paraglide.js"
27
+ },
28
+ "./adapters/i18next": {
29
+ "types": "./dist/adapters/i18next.d.ts",
30
+ "default": "./dist/adapters/i18next.js"
31
+ },
32
+ "./adapters/formatjs": {
33
+ "types": "./dist/adapters/formatjs.d.ts",
34
+ "default": "./dist/adapters/formatjs.js"
35
+ },
36
+ "./internal": {
37
+ "types": "./dist/internal.d.ts",
38
+ "default": "./dist/internal.js"
39
+ }
40
+ },
41
+ "imports": {
42
+ "#runtime": {
43
+ "locolize-inline": "./dist/inline/entry.js",
44
+ "default": "./dist/runtime-plain.js"
45
+ }
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "peerDependencies": {
51
+ "@formatjs/intl": "^6.0.0",
52
+ "i18next": ">=23"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "@formatjs/intl": {
56
+ "optional": true
57
+ },
58
+ "i18next": {
59
+ "optional": true
60
+ }
61
+ }
62
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.