@thekatze/locolize-core 0.0.0-stage → 0.1.0-test.1
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/dist/adapters/custom.d.ts +15 -0
- package/dist/adapters/custom.js +18 -0
- package/dist/adapters/formatjs.d.ts +15 -0
- package/dist/adapters/formatjs.js +45 -0
- package/dist/adapters/i18next.d.ts +11 -0
- package/dist/adapters/i18next.js +26 -0
- package/dist/adapters/messages.d.ts +50 -0
- package/dist/adapters/messages.js +35 -0
- package/dist/adapters/paraglide.d.ts +16 -0
- package/dist/adapters/paraglide.js +15 -0
- package/dist/create.d.ts +15 -0
- package/dist/create.js +17 -0
- package/dist/guards.d.ts +6 -0
- package/dist/guards.js +11 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1 -0
- package/dist/inline/editor.d.ts +37 -0
- package/dist/inline/editor.js +144 -0
- package/dist/inline/entry.d.ts +4 -0
- package/dist/inline/entry.js +6 -0
- package/dist/inline/oauth.d.ts +20 -0
- package/dist/inline/oauth.js +118 -0
- package/dist/inline/project.d.ts +11 -0
- package/dist/inline/project.js +54 -0
- package/dist/inline/runtime.d.ts +51 -0
- package/dist/inline/runtime.js +115 -0
- package/dist/inline/session.d.ts +17 -0
- package/dist/inline/session.js +73 -0
- package/dist/inline/variables.d.ts +10 -0
- package/dist/inline/variables.js +21 -0
- package/dist/internal.d.ts +10 -0
- package/dist/internal.js +6 -0
- package/dist/runtime-plain.d.ts +4 -0
- package/dist/runtime-plain.js +5 -0
- package/dist/runtime.d.ts +21 -0
- package/dist/runtime.js +21 -0
- package/dist/store.d.ts +13 -0
- package/dist/store.js +29 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.js +0 -0
- package/package.json +60 -4
- 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 };
|
package/dist/create.d.ts
ADDED
|
@@ -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 };
|
package/dist/guards.d.ts
ADDED
|
@@ -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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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,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 };
|
package/dist/internal.js
ADDED
|
@@ -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,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
|
package/dist/runtime.js
ADDED
|
@@ -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 };
|
package/dist/store.d.ts
ADDED
|
@@ -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 };
|
package/dist/types.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-test.1",
|
|
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