Move .ts i18n method generation to Rust

Based on a similar approach I used for AnkiDroid. The separate modules
file has been integrated into ftl.js.
This commit is contained in:
Damien Elmes 2023-07-03 14:32:57 +10:00
parent f3b6deefe9
commit 4c76e3150b
12 changed files with 136 additions and 168 deletions

View file

@ -1,5 +1,7 @@
[env]
STRINGS_JSON = { value = "out/rslib/i18n/strings.json", relative = true }
STRINGS_JS = { value = "out/ts/lib/ftl.js", relative = true }
STRINGS_DTS = { value = "out/ts/lib/ftl.d.ts", relative = true }
DESCRIPTORS_BIN = { value = "out/rslib/proto/descriptors.bin", relative = true }
# build script will append .exe if necessary
PROTOC = { value = "out/extracted/protoc/bin/protoc", relative = true }

1
Cargo.lock generated
View file

@ -182,6 +182,7 @@ dependencies = [
"fluent-syntax",
"inflections",
"intl-memoizer",
"itertools 0.11.0",
"num-format",
"phf 0.11.2",
"serde",

View file

@ -161,7 +161,6 @@ pub fn check_python(build: &mut Build) -> Result<()> {
PythonTypecheck {
folders: &[
"pylib",
"ts/lib",
"qt/aqt",
"qt/tools",
"out/pylib/anki",

View file

@ -3,7 +3,6 @@
use anyhow::Result;
use ninja_gen::action::BuildAction;
use ninja_gen::command::RunCommand;
use ninja_gen::glob;
use ninja_gen::hashmap;
use ninja_gen::input::BuildInput;
@ -115,26 +114,7 @@ fn setup_node(build: &mut Build) -> Result<()> {
}
fn build_and_check_tslib(build: &mut Build) -> Result<()> {
build.add_action(
"ts:lib:i18n",
RunCommand {
command: ":pyenv:bin",
args: "$script $strings $out",
inputs: hashmap! {
"script" => inputs!["ts/lib/genfluent.py"],
"strings" => inputs![":rslib:i18n:strings.json"],
"" => inputs!["pylib/anki/_vendor/stringcase.py"]
},
outputs: hashmap! {
"out" => vec![
"ts/lib/ftl.js",
"ts/lib/ftl.d.ts",
"ts/lib/i18n/modules.js",
"ts/lib/i18n/modules.d.ts"
]
},
},
)?;
build.add_dependency("ts:lib:i18n", ":rslib:i18n".into());
build.add_action(
"ts:lib:proto",
GenTypescriptProto {

View file

@ -22,6 +22,7 @@ serde_json.workspace = true
inflections.workspace = true
anki_io.workspace = true
anyhow.workspace = true
itertools.workspace = true
[dependencies]
fluent.workspace = true

View file

@ -4,6 +4,7 @@
mod check;
mod extract;
mod gather;
mod typescript;
mod write_strings;
use std::path::Path;
@ -25,6 +26,8 @@ fn main() -> Result<()> {
let modules = get_modules(&map);
write_strings(&map, &modules);
typescript::write_ts_interface(&modules)?;
// write strings.json file to requested path
println!("cargo:rerun-if-env-changed=STRINGS_JSON");
if let Some(path) = option_env!("STRINGS_JSON") {

View file

@ -0,0 +1,123 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::env;
use std::fmt::Write;
use std::path::PathBuf;
use anki_io::create_dir_all;
use anki_io::write_file_if_changed;
use anyhow::Result;
use inflections::Inflect;
use itertools::Itertools;
use crate::extract::Module;
use crate::extract::Variable;
use crate::extract::VariableKind;
pub fn write_ts_interface(modules: &[Module]) -> Result<()> {
let mut dts_out = header();
let mut js_out = header();
write_translate_method(&mut js_out);
dts_out.push_str("export declare const funcs: any;\n");
render_module_map(modules, &mut dts_out, &mut js_out);
render_methods(modules, &mut dts_out, &mut js_out);
if let Ok(path) = env::var("STRINGS_JS") {
let path = PathBuf::from(path);
create_dir_all(path.parent().unwrap())?;
write_file_if_changed(path, js_out)?;
}
if let Ok(path) = env::var("STRINGS_DTS") {
let path = PathBuf::from(path);
create_dir_all(path.parent().unwrap())?;
write_file_if_changed(path, dts_out)?;
}
Ok(())
}
fn render_module_map(modules: &[Module], dts_out: &mut String, js_out: &mut String) {
dts_out.push_str("export declare enum ModuleName {\n");
js_out.push_str("export const ModuleName = {};\n");
for module in modules {
let name = &module.name;
let upper = name.to_upper_case();
writeln!(dts_out, r#" {upper} = "{name}","#).unwrap();
writeln!(js_out, r#"ModuleName["{upper}"] = "{name}";"#).unwrap();
}
dts_out.push('}');
}
fn render_methods(modules: &[Module], dts_out: &mut String, js_out: &mut String) {
for module in modules {
for translation in &module.translations {
let text = &translation.text;
let key = &translation.key;
let func_name = key.replace('-', "_").to_camel_case();
let arg_types = get_arg_types(&translation.variables);
let maybe_args = if translation.variables.is_empty() {
""
} else {
"args"
};
writeln!(
dts_out,
"
/** {text} */
export declare function {func_name}({arg_types}): string;",
)
.unwrap();
writeln!(
js_out,
r#"
export function {func_name}({maybe_args}) {{
return translate("{key}", {maybe_args})
}}"#,
)
.unwrap();
}
}
}
fn write_translate_method(buf: &mut String) {
buf.push_str(
"
// tslib is responsible for injecting getMessage helper in
export const funcs = {};
function translate(key, args = {}) {
return funcs.getMessage(key, args) ?? `missing key: ${key}`;
}
",
);
}
fn header() -> String {
"// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
"
.to_string()
}
fn get_arg_types(args: &[Variable]) -> String {
let args = args
.iter()
.map(|arg| format!("{}: {}", arg.name.to_camel_case(), arg_kind(&arg.kind)))
.join(", ");
if args.is_empty() {
"".into()
} else {
format!("args: {{{args}}}",)
}
}
fn arg_kind(kind: &VariableKind) -> &str {
match kind {
VariableKind::Int | VariableKind::Float => "number",
VariableKind::String => "string",
VariableKind::Any => "number | string",
}
}

View file

@ -97,11 +97,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const settings = {
newGatherPriority: {
title: tr.deckConfigNewGatherPriority(),
help: tr.deckConfigNewGatherPriorityTooltip_2() + currentDeck,
help: tr.deckConfigNewGatherPriorityTooltip2() + currentDeck,
},
newCardSortOrder: {
title: tr.deckConfigNewCardSortOrder(),
help: tr.deckConfigNewCardSortOrderTooltip_2() + currentDeck,
help: tr.deckConfigNewCardSortOrderTooltip2() + currentDeck,
},
newReviewPriority: {
title: tr.deckConfigNewReviewPriority(),

View file

@ -59,7 +59,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$search = displayedSearch;
}
const year = tr.statisticsRange_1YearHistory();
const year = tr.statisticsRange1YearHistory();
const deck = tr.statisticsRangeDeck();
const collection = tr.statisticsRangeCollection();
const searchLabel = tr.statisticsRangeSearch();

View file

@ -1,141 +0,0 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import json
import sys
from typing import List, Literal, TypedDict
sys.path.append("pylib/anki/_vendor")
import stringcase
strings_json, ftl_js_path, ftl_dts_path, modules_js_path, modules_dts_path = sys.argv[
1:
]
with open(strings_json, encoding="utf8") as f:
modules = json.load(f)
class Variable(TypedDict):
name: str
kind: Literal["Any", "Int", "String", "Float"]
def write_methods() -> None:
js_out = [
"""
// tslib is responsible for injecting getMessage helper in
export const funcs = {};
function translate(key, args = {}) {
return funcs.getMessage(key, args) ?? `missing key: ${key}`;
}
"""
]
dts_out = ["export declare const funcs: any;"]
for module in modules:
for translation in module["translations"]:
key = stringcase.camelcase(translation["key"].replace("-", "_"))
arg_types = get_arg_name_and_types(translation["variables"])
args = get_args(translation["variables"])
doc = translation["text"]
dts_out.append(
f"""
/** {doc} */
export declare function {key}({arg_types}): string;
"""
)
js_out.append(
f"""
export function {key}({"args" if arg_types else ""}) {{
return translate("{translation["key"]}"{args})
}}
"""
)
write(ftl_dts_path, "\n".join(dts_out) + "\n")
write(ftl_js_path, "\n".join(js_out) + "\n")
def write_modules() -> None:
dts_buf = "export declare enum ModuleName {\n"
for module in modules:
name = module["name"]
upper = name.upper()
dts_buf += f' {upper} = "{name}",\n'
dts_buf += "}\n"
js_buf = "export const ModuleName = {};\n"
for module in modules:
name = module["name"]
upper = name.upper()
js_buf += f'ModuleName["{upper}"] = "{name}";\n'
write(modules_dts_path, dts_buf)
write(modules_js_path, js_buf)
def get_arg_name_and_types(args: List[Variable]) -> str:
if not args:
return ""
else:
return (
"args: {"
+ ", ".join(
[f"{typescript_arg_name(arg)}: {arg_kind(arg)}" for arg in args]
)
+ "}"
)
def arg_kind(arg: Variable) -> str:
if arg["kind"] in ("Int", "Float"):
return "number"
elif arg["kind"] == "Any":
return "number | string"
else:
return "string"
def map_args_to_real_names(args: List[Variable]) -> str:
return (
"{"
+ ", ".join(
[f'"{arg["name"]}": args.{typescript_arg_name(arg)}' for arg in args]
)
+ "}"
)
def get_args(args: List[Variable]) -> str:
if not args:
return ""
else:
for arg in args:
if typescript_arg_name(arg) != arg["name"]:
# we'll need to map variables to their fluent equiv
return ", " + map_args_to_real_names(args)
# variable names match, reference object instead
return ", args"
def typescript_arg_name(arg: Variable) -> str:
name = stringcase.camelcase(arg["name"].replace("-", "_"))
if name == "new":
return "new_"
else:
return name
def write(outfile, out) -> None:
with open(outfile, "w", encoding="utf8") as f:
f.write(
f"""// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
"""
+ out
)
write_methods()
write_modules()

View file

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export * from "../../../out/ts/lib/i18n/modules";
export { ModuleName } from "../../../out/ts/lib/ftl";
export * from "./bundles";
export * from "./utils";
// this is an ugly hack to inject code into a generated module

View file

@ -5,9 +5,9 @@ import "intl-pluralrules";
import { FluentBundle, FluentResource } from "@fluent/bundle";
import { i18nResources } from "@tslib/backend";
import type { ModuleName } from "@tslib/ftl";
import { firstLanguage, setBundles } from "./bundles";
import type { ModuleName } from "./modules";
export function supportsVerticalText(): boolean {
const firstLang = firstLanguage();