diff --git a/ts/.prettierignore b/ts/.prettierignore
index ac995e48b..339fa65b6 100644
--- a/ts/.prettierignore
+++ b/ts/.prettierignore
@@ -1,4 +1,5 @@
licenses.json
vendor
-*.svelte.d.ts
+lib/translate.ts
+lib/i18n/modules.ts
backend_proto.d.ts
diff --git a/ts/change-notetype/ChangeNotetypePage.svelte b/ts/change-notetype/ChangeNotetypePage.svelte
index 878114145..03dc13305 100644
--- a/ts/change-notetype/ChangeNotetypePage.svelte
+++ b/ts/change-notetype/ChangeNotetypePage.svelte
@@ -3,7 +3,7 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
diff --git a/ts/graphs/EaseGraph.svelte b/ts/graphs/EaseGraph.svelte
index 2a2f0ceae..de8568311 100644
--- a/ts/graphs/EaseGraph.svelte
+++ b/ts/graphs/EaseGraph.svelte
@@ -4,10 +4,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
diff --git a/ts/graphs/RangeBox.svelte b/ts/graphs/RangeBox.svelte
index b4285beb5..10bc3e936 100644
--- a/ts/graphs/RangeBox.svelte
+++ b/ts/graphs/RangeBox.svelte
@@ -3,11 +3,10 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
-
+
{#each tableData as { label, value }}
{label}: |
diff --git a/ts/graphs/added.ts b/ts/graphs/added.ts
index db4877c85..dca8ad566 100644
--- a/ts/graphs/added.ts
+++ b/ts/graphs/added.ts
@@ -19,10 +19,10 @@ import {
import type { Bin } from "d3";
import type { HistogramData } from "./histogram-graph";
+import * as tr from "../lib/ftl";
import { dayLabel } from "../lib/time";
import { GraphRange } from "./graph-helpers";
import type { TableDatum, SearchDispatch } from "./graph-helpers";
-import * as tr from "../lib/i18n";
export interface GraphData {
daysAdded: number[];
diff --git a/ts/graphs/buttons.ts b/ts/graphs/buttons.ts
index 3c77166e6..10ae799a9 100644
--- a/ts/graphs/buttons.ts
+++ b/ts/graphs/buttons.ts
@@ -6,8 +6,6 @@
@typescript-eslint/no-explicit-any: "off",
*/
-import { Stats } from "../lib/proto";
-
import {
interpolateRdYlGn,
select,
@@ -19,6 +17,8 @@ import {
axisLeft,
sum,
} from "d3";
+import { Stats } from "../lib/proto";
+import * as tr from "../lib/ftl";
import { showTooltip, hideTooltip } from "./tooltip";
import {
GraphBounds,
@@ -26,7 +26,6 @@ import {
GraphRange,
millisecondCutoffForRange,
} from "./graph-helpers";
-import * as tr from "../lib/i18n";
type ButtonCounts = [number, number, number, number];
diff --git a/ts/graphs/calendar.ts b/ts/graphs/calendar.ts
index 3bea00fbb..4797ab0a9 100644
--- a/ts/graphs/calendar.ts
+++ b/ts/graphs/calendar.ts
@@ -29,8 +29,8 @@ import {
SearchDispatch,
} from "./graph-helpers";
import { clickableClass } from "./graph-styles";
-import { i18n } from "../lib/i18n";
-import * as tr from "../lib/i18n";
+import { weekdayLabel, toLocaleString } from "../lib/i18n";
+import * as tr from "../lib/ftl";
export interface GraphData {
// indexed by day, where day is relative to today
@@ -155,7 +155,7 @@ export function renderCalendar(
.interpolator((n) => interpolateBlues(cappedRange(n)!));
function tooltipText(d: DayDatum): string {
- const date = d.date.toLocaleString(i18n.langs, {
+ const date = toLocaleString(d.date, {
weekday: "long",
year: "numeric",
month: "long",
@@ -172,7 +172,7 @@ export function renderCalendar(
.selectAll("text")
.data(sourceData.weekdayLabels)
.join("text")
- .text((d: number) => i18n.weekdayLabel(d))
+ .text((d: number) => weekdayLabel(d))
.attr("width", x(-1)! - 2)
.attr("height", height - 2)
.attr("x", x(1)! - 3)
diff --git a/ts/graphs/card-counts.ts b/ts/graphs/card-counts.ts
index 0d2f0faf7..7897982af 100644
--- a/ts/graphs/card-counts.ts
+++ b/ts/graphs/card-counts.ts
@@ -6,6 +6,7 @@
@typescript-eslint/no-explicit-any: "off",
*/
+import * as tr from "../lib/ftl";
import { CardQueue, CardType } from "../lib/cards";
import type { Stats, Cards } from "../lib/proto";
import {
@@ -22,8 +23,6 @@ import {
} from "d3";
import type { GraphBounds } from "./graph-helpers";
-import * as tr from "../lib/i18n";
-
type Count = [string, number, boolean, string];
export interface GraphData {
title: string;
diff --git a/ts/graphs/ease.ts b/ts/graphs/ease.ts
index 5b28ee58d..1ab829900 100644
--- a/ts/graphs/ease.ts
+++ b/ts/graphs/ease.ts
@@ -16,11 +16,10 @@ import {
interpolateRdYlGn,
} from "d3";
import type { Bin, ScaleLinear } from "d3";
+import * as tr from "../lib/ftl";
import { CardType } from "../lib/cards";
import type { HistogramData } from "./histogram-graph";
-
import type { TableDatum, SearchDispatch } from "./graph-helpers";
-import * as tr from "../lib/i18n";
export interface GraphData {
eases: number[];
diff --git a/ts/graphs/future-due.ts b/ts/graphs/future-due.ts
index 67d5ba0ed..2c132fea5 100644
--- a/ts/graphs/future-due.ts
+++ b/ts/graphs/future-due.ts
@@ -17,13 +17,13 @@ import {
interpolateGreens,
} from "d3";
import type { Bin } from "d3";
+import * as tr from "../lib/ftl";
import { CardQueue } from "../lib/cards";
import type { HistogramData } from "./histogram-graph";
import { dayLabel } from "../lib/time";
import { GraphRange } from "./graph-helpers";
import type { TableDatum, SearchDispatch } from "./graph-helpers";
-import * as tr from "../lib/i18n";
export interface GraphData {
dueCounts: Map;
diff --git a/ts/graphs/hours.ts b/ts/graphs/hours.ts
index 1508c6928..3724bb301 100644
--- a/ts/graphs/hours.ts
+++ b/ts/graphs/hours.ts
@@ -21,6 +21,7 @@ import {
curveBasis,
} from "d3";
+import * as tr from "../lib/ftl";
import { showTooltip, hideTooltip } from "./tooltip";
import {
GraphBounds,
@@ -29,7 +30,6 @@ import {
millisecondCutoffForRange,
} from "./graph-helpers";
import { oddTickClass } from "./graph-styles";
-import * as tr from "../lib/i18n";
interface Hour {
hour: number;
diff --git a/ts/graphs/intervals.ts b/ts/graphs/intervals.ts
index 22372a03c..11c796659 100644
--- a/ts/graphs/intervals.ts
+++ b/ts/graphs/intervals.ts
@@ -18,12 +18,12 @@ import {
interpolateBlues,
} from "d3";
import type { Bin } from "d3";
+import * as tr from "../lib/ftl";
+import { timeSpan } from "../lib/time";
import { CardType } from "../lib/cards";
import type { HistogramData } from "./histogram-graph";
import type { TableDatum, SearchDispatch } from "./graph-helpers";
-import { timeSpan } from "../lib/time";
-import * as tr from "../lib/i18n";
export interface IntervalGraphData {
intervals: number[];
diff --git a/ts/graphs/reviews.ts b/ts/graphs/reviews.ts
index f31f7e161..00a6a2617 100644
--- a/ts/graphs/reviews.ts
+++ b/ts/graphs/reviews.ts
@@ -32,10 +32,10 @@ import {
} from "d3";
import type { Bin } from "d3";
+import * as tr from "../lib/ftl";
import type { TableDatum } from "./graph-helpers";
import { GraphBounds, setDataAvailable, GraphRange } from "./graph-helpers";
import { showTooltip, hideTooltip } from "./tooltip";
-import * as tr from "../lib/i18n";
interface Reviews {
learn: number;
diff --git a/ts/graphs/today.ts b/ts/graphs/today.ts
index 07e5a12e7..568e536dc 100644
--- a/ts/graphs/today.ts
+++ b/ts/graphs/today.ts
@@ -3,8 +3,7 @@
import { Stats } from "../lib/proto";
import { studiedToday } from "../lib/time";
-
-import * as tr from "../lib/i18n";
+import * as tr from "../lib/ftl";
export interface TodayData {
title: string;
diff --git a/ts/lib/BUILD.bazel b/ts/lib/BUILD.bazel
index 10811be31..14bb8df24 100644
--- a/ts/lib/BUILD.bazel
+++ b/ts/lib/BUILD.bazel
@@ -1,17 +1,11 @@
+load("@rules_python//python:defs.bzl", "py_binary")
load("//ts:prettier.bzl", "prettier_test")
load("//ts:eslint.bzl", "eslint_test")
load("//ts:protobuf.bzl", "protobufjs_library")
load("//ts:typescript.bzl", "typescript")
-load("@rules_python//python:defs.bzl", "py_binary")
load("@py_deps//:requirements.bzl", "requirement")
load("//ts:jest.bzl", "jest_test")
-protobufjs_library(
- name = "backend_proto",
- proto = "//proto:backend_proto_lib",
- visibility = ["//visibility:public"],
-)
-
py_binary(
name = "genfluent",
srcs = [
@@ -22,21 +16,33 @@ py_binary(
requirement("stringcase"),
],
)
+_i18n_generated = [
+ "ftl.ts",
+ "i18n/modules.ts",
+]
genrule(
name = "fluent_gen",
- outs = ["i18n.ts"],
- cmd = "$(location genfluent) $(location //rslib/i18n:strings.json) $@",
+ outs = _i18n_generated,
+ cmd = "$(location genfluent) $(location //rslib/i18n:strings.json) $(OUTS)",
tools = [
"genfluent",
"//rslib/i18n:strings.json",
],
)
+protobufjs_library(
+ name = "backend_proto",
+ proto = "//proto:backend_proto_lib",
+ visibility = ["//visibility:public"],
+)
+
typescript(
name = "lib",
generated = [
- ":i18n.ts",
+ ":backend_proto.d.ts",
+ ":ftl.ts",
+ ":i18n/modules.ts",
],
deps = [
":backend_proto",
diff --git a/ts/lib/genfluent.py b/ts/lib/genfluent.py
index 3944aad5f..075707ed2 100644
--- a/ts/lib/genfluent.py
+++ b/ts/lib/genfluent.py
@@ -7,7 +7,7 @@ from typing import List, Literal, TypedDict
import stringcase
-strings_json, outfile = sys.argv[1:]
+strings_json, translate_out, modules_out = sys.argv[1:]
modules = json.load(open(strings_json, encoding="utf8"))
@@ -17,10 +17,12 @@ class Variable(TypedDict):
def methods() -> str:
- out = [
- 'import { i18n } from "./i18n_helpers";',
- 'export { i18n, setupI18n } from "./i18n_helpers";',
- ]
+ out = [ """import type { FluentVariable } from "@fluent/bundle";
+import { getMessage } from "./i18n";
+
+function translate(key: string, args: Record = {}): string {
+ return getMessage(key, args) ?? `missing key: ${key}`;
+}""" ]
for module in modules:
for translation in module["translations"]:
key = stringcase.camelcase(translation["key"].replace("-", "_"))
@@ -31,7 +33,7 @@ def methods() -> str:
f"""
/** {doc} */
export function {key}({arg_types}): string {{
- return i18n.translate("{translation["key"]}"{args})
+ return translate("{translation["key"]}"{args})
}}
"""
)
@@ -102,17 +104,17 @@ def module_names() -> str:
return buf
-out = ""
-
-out += methods()
-out += module_names()
-
-open(outfile, "wb").write(
- (
- """// Copyright: Ankitects Pty Ltd and contributors
+def write(outfile, out) -> None:
+ open(outfile, "wb").write(
+ (
+ f"""// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
"""
- + out
- ).encode("utf8")
-)
+ + out
+ ).encode("utf8")
+ )
+
+
+write(translate_out, str(methods()))
+write(modules_out, str(module_names()))
diff --git a/ts/lib/i18n/bundles.ts b/ts/lib/i18n/bundles.ts
new file mode 100644
index 000000000..d83860ea3
--- /dev/null
+++ b/ts/lib/i18n/bundles.ts
@@ -0,0 +1,46 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+import { FluentNumber } from "@fluent/bundle";
+import type { FluentBundle, FluentVariable } from "@fluent/bundle";
+
+let bundles: FluentBundle[] = [];
+
+export function setBundles(newBundles: FluentBundle[]): void {
+ bundles = newBundles;
+}
+
+export function firstLanguage(): string {
+ return bundles[0].locales[0];
+}
+
+function toFluentNumber(num: number): FluentNumber {
+ return new FluentNumber(num, {
+ maximumFractionDigits: 2,
+ });
+}
+
+function formatArgs(
+ args: Record
+): Record {
+ return Object.fromEntries(
+ Object.entries(args).map(([key, value]) => [
+ key,
+ typeof value === "number" ? toFluentNumber(value) : value,
+ ])
+ );
+}
+
+export function getMessage(
+ key: string,
+ args: Record = {}
+): string | null {
+ for (const bundle of bundles) {
+ const msg = bundle.getMessage(key);
+ if (msg && msg.value) {
+ return bundle.formatPattern(msg.value, formatArgs(args));
+ }
+ }
+
+ return null;
+}
diff --git a/ts/lib/i18n/index.ts b/ts/lib/i18n/index.ts
new file mode 100644
index 000000000..d547a93c0
--- /dev/null
+++ b/ts/lib/i18n/index.ts
@@ -0,0 +1,6 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+export * from "./bundles";
+export * from "./modules";
+export * from "./utils";
diff --git a/ts/lib/i18n/utils.ts b/ts/lib/i18n/utils.ts
new file mode 100644
index 000000000..9ff8d9e4f
--- /dev/null
+++ b/ts/lib/i18n/utils.ts
@@ -0,0 +1,87 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+import "intl-pluralrules";
+import { FluentBundle, FluentResource } from "@fluent/bundle";
+
+import { firstLanguage, setBundles } from "./bundles";
+import type { ModuleName } from "./modules";
+
+export function supportsVerticalText(): boolean {
+ const firstLang = firstLanguage();
+ return (
+ firstLang.startsWith("ja") ||
+ firstLang.startsWith("zh") ||
+ firstLang.startsWith("ko")
+ );
+}
+
+export function direction(): string {
+ const firstLang = firstLanguage();
+ if (
+ firstLang.startsWith("ar") ||
+ firstLang.startsWith("he") ||
+ firstLang.startsWith("fa")
+ ) {
+ return "rtl";
+ } else {
+ return "ltr";
+ }
+}
+
+export function weekdayLabel(n: number): string {
+ const firstLang = firstLanguage();
+ const now = new Date();
+ const daysFromToday = -now.getDay() + n;
+ const desiredDay = new Date(now.getTime() + daysFromToday * 86_400_000);
+ return desiredDay.toLocaleDateString(firstLang, {
+ weekday: "narrow",
+ });
+}
+
+let langs: string[] = [];
+
+export function toLocaleString(
+ date: Date,
+ options?: Intl.DateTimeFormatOptions
+): string {
+ return date.toLocaleDateString(langs, options);
+}
+
+export function localeCompare(
+ first: string,
+ second: string,
+ options?: Intl.CollatorOptions
+): number {
+ return first.localeCompare(second, langs, options);
+}
+
+/// Treat text like HTML, merging multiple spaces and converting
+/// newlines to spaces.
+export function withCollapsedWhitespace(s: string): string {
+ return s.replace(/\s+/g, " ");
+}
+
+export async function setupI18n(args: { modules: ModuleName[] }): Promise {
+ const resp = await fetch("/_anki/i18nResources", {
+ method: "POST",
+ body: JSON.stringify(args),
+ });
+ if (!resp.ok) {
+ throw Error(`unexpected reply: ${resp.statusText}`);
+ }
+ const json = await resp.json();
+
+ const newBundles: FluentBundle[] = [];
+ for (const i in json.resources) {
+ const text = json.resources[i];
+ const lang = json.langs[i];
+ const bundle = new FluentBundle([lang, "en-US"]);
+ const resource = new FluentResource(text);
+ bundle.addResource(resource);
+ newBundles.push(bundle);
+ }
+
+ setBundles(newBundles);
+ langs = json.langs;
+}
diff --git a/ts/lib/i18n_helpers.ts b/ts/lib/i18n_helpers.ts
deleted file mode 100644
index 5852d6444..000000000
--- a/ts/lib/i18n_helpers.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright: Ankitects Pty Ltd and contributors
-// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-// An i18n singleton and setupI18n is re-exported via the generated i18n.ts file,
-// so you should not need to access this file directly.
-
-import "intl-pluralrules";
-import { FluentBundle, FluentResource, FluentNumber } from "@fluent/bundle";
-
-type RecordVal = number | string | FluentNumber;
-
-function formatNumbers(args?: Record): void {
- if (!args) {
- return;
- }
- for (const key of Object.keys(args)) {
- if (typeof args[key] === "number") {
- args[key] = new FluentNumber(args[key] as number, {
- maximumFractionDigits: 2,
- });
- }
- }
-}
-
-export class I18n {
- bundles: FluentBundle[] = [];
- langs: string[] = [];
-
- translate(key: string, args?: Record): string {
- formatNumbers(args);
- for (const bundle of this.bundles) {
- const msg = bundle.getMessage(key);
- if (msg && msg.value) {
- return bundle.formatPattern(msg.value, args);
- }
- }
- return `missing key: ${key}`;
- }
-
- supportsVerticalText(): boolean {
- const firstLang = this.bundles[0].locales[0];
- return (
- firstLang.startsWith("ja") ||
- firstLang.startsWith("zh") ||
- firstLang.startsWith("ko")
- );
- }
-
- direction(): string {
- const firstLang = this.bundles[0].locales[0];
- if (
- firstLang.startsWith("ar") ||
- firstLang.startsWith("he") ||
- firstLang.startsWith("fa")
- ) {
- return "rtl";
- } else {
- return "ltr";
- }
- }
-
- weekdayLabel(n: number): string {
- const firstLang = this.bundles[0].locales[0];
- const now = new Date();
- const daysFromToday = -now.getDay() + n;
- const desiredDay = new Date(now.getTime() + daysFromToday * 86_400_000);
- return desiredDay.toLocaleDateString(firstLang, {
- weekday: "narrow",
- });
- }
-
- /// Treat text like HTML, merging multiple spaces and converting
- /// newlines to spaces.
- withCollapsedWhitespace(s: string): string {
- return s.replace(/\s+/g, " ");
- }
-}
-
-// global singleton
-export const i18n = new I18n();
-
-import type { ModuleName } from "./i18n";
-
-export async function setupI18n(args: { modules: ModuleName[] }): Promise {
- const resp = await fetch("/_anki/i18nResources", {
- method: "POST",
- body: JSON.stringify(args),
- });
- if (!resp.ok) {
- throw Error(`unexpected reply: ${resp.statusText}`);
- }
- const json = await resp.json();
-
- i18n.bundles = [];
- for (const i in json.resources) {
- const text = json.resources[i];
- const lang = json.langs[i];
- const bundle = new FluentBundle([lang, "en-US"]);
- const resource = new FluentResource(text);
- bundle.addResource(resource);
- i18n.bundles.push(bundle);
- }
- i18n.langs = json.langs;
-}
diff --git a/ts/lib/keys.ts b/ts/lib/keys.ts
index ef7bcad15..8de5acd76 100644
--- a/ts/lib/keys.ts
+++ b/ts/lib/keys.ts
@@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-import * as tr from "./i18n";
+import * as tr from "./ftl";
import { isApplePlatform } from "./platform";
// those are the modifiers that Anki works with
diff --git a/ts/lib/proto.ts b/ts/lib/proto.ts
index 08b1a8853..e182f6c10 100644
--- a/ts/lib/proto.ts
+++ b/ts/lib/proto.ts
@@ -2,6 +2,7 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { anki } from "./backend_proto";
+
import Cards = anki.cards;
import DeckConfig = anki.deckconfig;
import Notetypes = anki.notetypes;
diff --git a/ts/lib/time.ts b/ts/lib/time.ts
index c59e4fd8a..18d9263e8 100644
--- a/ts/lib/time.ts
+++ b/ts/lib/time.ts
@@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-import * as tr from "./i18n";
+import * as tr from "./ftl";
export const SECOND = 1.0;
export const MINUTE = 60.0 * SECOND;
diff --git a/ts/lib/tsconfig.json b/ts/lib/tsconfig.json
index 3614760cc..39f73a570 100644
--- a/ts/lib/tsconfig.json
+++ b/ts/lib/tsconfig.json
@@ -1,6 +1,6 @@
{
"extends": "../tsconfig.json",
- "include": ["*", "../../bazel-bin/ts/lib/*"],
+ "include": ["*", "i18n/*"],
"references": [],
"compilerOptions": {
"types": ["jest"]
diff --git a/ts/tsconfig.json b/ts/tsconfig.json
index 7c93dc9dd..636dcbbf9 100644
--- a/ts/tsconfig.json
+++ b/ts/tsconfig.json
@@ -17,14 +17,15 @@
"declaration": true,
"composite": true,
"target": "es6",
- "module": "es6",
+ "module": "es2020",
"lib": [
"es2017",
"es2018.intl",
- "es2019.array",
"es2018.promise",
- "es2020.promise",
+ "es2019.array",
+ "es2019.object",
"es2019.string",
+ "es2020.promise",
"dom",
"dom.iterable"
],
diff --git a/ts/typescript.bzl b/ts/typescript.bzl
index 26dce9894..69ecadc09 100644
--- a/ts/typescript.bzl
+++ b/ts/typescript.bzl
@@ -4,12 +4,13 @@ load("@build_bazel_rules_nodejs//:index.bzl", "copy_to_bin", "js_library")
def typescript(
name,
srcs = None,
+ exclude = [],
generated = [],
tsconfig = "tsconfig.json",
visibility = ["//visibility:public"],
**kwargs):
if not srcs:
- srcs = native.glob(["**/*.ts"])
+ srcs = native.glob(["**/*.ts"], exclude = exclude)
# all tsconfig files must be in the bazel-out folder
copy_to_bin(