diff --git a/.changeset/brave-chairs-cheat.md b/.changeset/brave-chairs-cheat.md new file mode 100644 index 0000000000..10c26e60f1 --- /dev/null +++ b/.changeset/brave-chairs-cheat.md @@ -0,0 +1,5 @@ +--- +"@inlang/paraglide-js": minor +--- + +paraglide deprecate aliases diff --git a/.changeset/twenty-terms-remember.md b/.changeset/twenty-terms-remember.md new file mode 100644 index 0000000000..c036ef0138 --- /dev/null +++ b/.changeset/twenty-terms-remember.md @@ -0,0 +1,23 @@ +--- +"@inlang/project-settings": minor +"@inlang/message": minor +"@inlang/paraglide-js": minor +"vs-code-extension": minor +"@inlang/cli": minor +"@inlang/rpc": minor +"@inlang/sdk": minor +"@lix-js/client": minor +"@inlang/message-lint-rule-without-source": patch +"@inlang/message-lint-rule-missing-translation": patch +"@inlang/message-lint-rule-identical-pattern": patch +"@inlang/message-lint-rule-empty-pattern": patch +"@inlang/message-lint-rule-snake-case-id": patch +"@inlang/plugin-message-format": patch +"@inlang/plugin-next-intl": patch +"@inlang/plugin-i18next": patch +"@inlang/plugin-json": patch +"@inlang/badge": patch +--- + +File locking for concurrent message updates through the load/store plugin api +Auto-generated human-IDs and aliases - only with experimental: { aliases: true } diff --git a/inlang/source-code/badge/src/helper/calculateSummary.test.ts b/inlang/source-code/badge/src/helper/calculateSummary.test.ts index baf5600fbe..f574fca644 100644 --- a/inlang/source-code/badge/src/helper/calculateSummary.test.ts +++ b/inlang/source-code/badge/src/helper/calculateSummary.test.ts @@ -134,6 +134,7 @@ it("should work with multiple resources", () => { export const createMessage = (id: string, patterns: Record) => ({ id, + alias: {}, selectors: [], variants: Object.entries(patterns).map(([languageTag, patterns]) => ({ languageTag, diff --git a/inlang/source-code/cli/src/commands/lint/lint.test.ts b/inlang/source-code/cli/src/commands/lint/lint.test.ts index d166e8c4ff..e66af17435 100644 --- a/inlang/source-code/cli/src/commands/lint/lint.test.ts +++ b/inlang/source-code/cli/src/commands/lint/lint.test.ts @@ -13,6 +13,7 @@ import { mockRepo } from "@lix-js/client" const exampleMessages: Message[] = [ { id: "a", + alias: {}, selectors: [], variants: [ { @@ -29,6 +30,7 @@ const exampleMessages: Message[] = [ }, { id: "b", + alias: {}, selectors: [], variants: [ { diff --git a/inlang/source-code/cli/src/commands/machine/translate.test.ts b/inlang/source-code/cli/src/commands/machine/translate.test.ts index d728d40340..ee72336fcd 100644 --- a/inlang/source-code/cli/src/commands/machine/translate.test.ts +++ b/inlang/source-code/cli/src/commands/machine/translate.test.ts @@ -73,7 +73,8 @@ test.runIf(process.env.GOOGLE_TRANSLATE_API_KEY)( } } } - } + }, + { timeout: 10000 } ) test.runIf(process.env.GOOGLE_TRANSLATE_API_KEY)( @@ -82,6 +83,7 @@ test.runIf(process.env.GOOGLE_TRANSLATE_API_KEY)( const exampleMessages: Message[] = [ { id: "a", + alias: {}, selectors: [], variants: [ { @@ -144,11 +146,19 @@ test.runIf(process.env.GOOGLE_TRANSLATE_API_KEY)( const messages = project.query.messages.getAll() expect(messages[0]?.variants.length).toBe(2) - expect(messages[0]?.variants[1]?.languageTag).toBe("de") - expect( - messages[0]?.variants[1]?.pattern.some( - (value) => value.type === "VariableReference" && value.name === "username" - ) - ).toBeTruthy() - } + expect(messages[0]?.variants.map((variant) => variant.languageTag).sort()).toStrictEqual([ + "de", + "en", + ]) + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we expected the message to exist earlier + for (const variant of messages[0]!.variants) { + expect( + variant.pattern.some( + (value) => value.type === "VariableReference" && value.name === "username" + ) + ).toBeTruthy() + } + }, + { timeout: 10000 } ) diff --git a/inlang/source-code/cli/src/commands/machine/translate.ts b/inlang/source-code/cli/src/commands/machine/translate.ts index 6febf70323..eea2a2e217 100644 --- a/inlang/source-code/cli/src/commands/machine/translate.ts +++ b/inlang/source-code/cli/src/commands/machine/translate.ts @@ -2,7 +2,7 @@ import { Command } from "commander" import { rpc } from "@inlang/rpc" import { getInlangProject } from "../../utilities/getInlangProject.js" -import { log } from "../../utilities/log.js" +import { log, logError } from "../../utilities/log.js" import { type InlangProject, ProjectSettings, Message } from "@inlang/sdk" import prompts from "prompts" import { projectOption } from "../../utilities/globalFlags.js" @@ -40,7 +40,7 @@ export const translate = new Command() const project = await getInlangProject({ projectPath: args.project }) await translateCommandAction({ project }) } catch (error) { - log.error(error) + logError(error) } }) @@ -53,6 +53,7 @@ export async function translateCommandAction(args: { project: InlangProject }) { log.error(`No inlang project found`) return } + const experimentalAliases = args.project.settings().experimental?.aliases const allLanguageTags = [...projectConfig.languageTags, projectConfig.sourceLanguageTag] @@ -106,20 +107,24 @@ export async function translateCommandAction(args: { project: InlangProject }) { const rpcTranslate = async (id: Message["id"]) => { const toBeTranslatedMessage = args.project.query.messages.get({ where: { id } })! + const logId = + `"${id}"` + + (experimentalAliases ? ` (alias "${toBeTranslatedMessage.alias.default ?? ""}")` : "") + const { data: translatedMessage, error } = await rpc.machineTranslateMessage({ message: toBeTranslatedMessage, sourceLanguageTag, targetLanguageTags, }) if (error) { - logs.push(() => log.error(`Couldn't translate message "${id}": ${error}`)) + logs.push(() => log.error(`Couldn't translate message ${logId}: ${error}`)) return } else if ( translatedMessage && translatedMessage?.variants.length > toBeTranslatedMessage.variants.length ) { args.project.query.messages.update({ where: { id: id }, data: translatedMessage! }) - logs.push(() => log.info(`Machine translated message "${id}"`)) + logs.push(() => log.info(`Machine translated message ${logId}`)) } bar.increment() } @@ -133,12 +138,9 @@ export async function translateCommandAction(args: { project: InlangProject }) { log() } - // https://github.com/opral/monorepo/issues/1846 - // https://github.com/opral/monorepo/issues/1968 - await new Promise((resolve) => setTimeout(resolve, 8002)) // Log the message counts log.success("Machine translate complete.") } catch (error) { - log.error(error) + logError(error) } } diff --git a/inlang/source-code/cli/src/main.ts b/inlang/source-code/cli/src/main.ts index d57a21a99a..090cd2dd5f 100644 --- a/inlang/source-code/cli/src/main.ts +++ b/inlang/source-code/cli/src/main.ts @@ -56,6 +56,5 @@ export const cli = new Command() version, }, }) - // https://github.com/tj/commander.js/issues/1745 - process.exit(0) + // process should exit by itself once promises are resolved }) diff --git a/inlang/source-code/cli/src/utilities/log.ts b/inlang/source-code/cli/src/utilities/log.ts index 907cb5745d..226f9cf154 100644 --- a/inlang/source-code/cli/src/utilities/log.ts +++ b/inlang/source-code/cli/src/utilities/log.ts @@ -9,3 +9,17 @@ import consola from "consola" * log.success("Success") */ export const log = consola + +export function logError(error: any) { + log.error(causeString(error), error) +} + +// Convert error.cause into a string for logging +export function causeString(error: any) { + if (typeof error === "object" && error.cause) { + if (error.cause.errors?.length) return error.cause.errors.join(", ") + if (error.cause.code) return "" + error.cause.code + return JSON.stringify(error.cause) + } + return "" +} diff --git a/inlang/source-code/editor/messages.json b/inlang/source-code/editor/messages.json deleted file mode 100644 index a3347bc06a..0000000000 --- a/inlang/source-code/editor/messages.json +++ /dev/null @@ -1,2505 +0,0 @@ -{ - "$schema": "https://inlang.com/schema/inlang-message-format", - "data": [ - { - "id": "footer_category_application", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Global Application" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Globale Anwendung" - } - ] - } - ] - }, - { - "id": "footer_category_lint", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Lint Rules" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Lint Regeln" - } - ] - } - ] - }, - { - "id": "footer_category_markdown", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Global Markdown" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Globales Markdown" - } - ] - } - ] - }, - { - "id": "footer_category_title", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Categories" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Kategorien" - } - ] - } - ] - }, - { - "id": "footer_category_website", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Global Website" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Globale Webseite" - } - ] - } - ] - }, - { - "id": "footer_contact_blog", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Blog" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Blog" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Blog" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Blog" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "博客" - } - ] - } - ] - }, - { - "id": "footer_contact_feedback", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Feedback" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Feedback" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Feedback" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Spätná väzba" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "反馈" - } - ] - } - ] - }, - { - "id": "footer_contact_getInTouch", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "In Kontakt treten" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Get in Touch" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Fale com a gente" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Kontaktujte nás" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "联系我们" - } - ] - } - ] - }, - { - "id": "footer_contact_join", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Bewerben" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Join the Team" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Faça parte da equipe" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Pridajte sa k tímu" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "加入团队" - } - ] - } - ] - }, - { - "id": "footer_contact_title", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Kontakt" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Let's talk" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Vamos conversar" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Poďme sa rozprávať" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "联系" - } - ] - } - ] - }, - { - "id": "footer_documentation_contribute", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Mitwirken" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Contribute" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Contribua" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Prispieť" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "贡献指南" - } - ] - } - ] - }, - { - "id": "footer_documentation_gettingStarted", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Loslegen" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Getting Started" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Começando agora" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Začať" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "使用入门指南" - } - ] - } - ] - }, - { - "id": "footer_documentation_title", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Dokumentation" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Documentation" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Documentação" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Dokumentácia" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "文档" - } - ] - } - ] - }, - { - "id": "footer_documentation_whyInlang", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Warum inlang?" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Why inlang?" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Porquê o inlang?" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Prečo inlang?" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "为什么是 inlang?" - } - ] - } - ] - }, - { - "id": "footer_inlang_tagline", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "The ecosystem to go global" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Das Ökosystem um global zu gehen" - } - ] - } - ] - }, - { - "id": "footer_resources_discord", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Discord" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Discord" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Discord" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Discord" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "Discord" - } - ] - } - ] - }, - { - "id": "footer_resources_github", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "GitHub" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "GitHub" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "GitHub" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "GitHub" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "GitHub" - } - ] - } - ] - }, - { - "id": "footer_resources_marketplace", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Marktplatz" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Marketplace" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Marketplace" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Trhovisko" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "市场" - } - ] - } - ] - }, - { - "id": "footer_resources_roadmap", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Produktplan" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Roadmap" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Roadmap" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Roadmap" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "发展计划" - } - ] - } - ] - }, - { - "id": "footer_resources_title", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Ressourcen" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Resources" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Recursos" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Zdroje" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "资源" - } - ] - } - ] - }, - { - "id": "footer_resources_twitter", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "X" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "X" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "X" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "X" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "推特" - } - ] - } - ] - }, - { - "id": "home_featured_title", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Featured" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Hervorgehoben" - } - ] - } - ] - }, - { - "id": "home_inlang_button", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "More about the ecosystem" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Mehr zum Ökosystem" - } - ] - } - ] - }, - { - "id": "home_inlang_description", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Expand to new markets and acquire more customers." - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Erschließe neue Märkte und gewinne mehr Kunden." - } - ] - } - ] - }, - { - "id": "home_inlang_title_first_part", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Welcome to inlang," - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Willkommen bei inlang," - } - ] - } - ] - }, - { - "id": "home_inlang_title_second_part", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "the ecosystem to go global." - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "das Ökosystem um global zu gehen." - } - ] - } - ] - }, - { - "id": "home_lix_button", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "More about Lix" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Mehr über Lix" - } - ] - } - ] - }, - { - "id": "home_lix_description", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "The backbone of the ecosystem" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Die Basis des Ökosystems" - } - ] - } - ] - }, - { - "id": "home_lix_title", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Lix change control" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Lix-Änderungskontrolle" - } - ] - } - ] - }, - { - "id": "home_stack_title", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Stack" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Stack" - } - ] - } - ] - }, - { - "id": "marketplace_grid_build_your_own_description", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Build your own solution!" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Baue deine eigene Lösung!" - } - ] - } - ] - }, - { - "id": "marketplace_grid_build_your_own_title", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Can't find what you are looking for?" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Du kannst nicht finden, was du suchst?" - } - ] - } - ] - }, - { - "id": "marketplace_grid_need_help", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Need help or have questions? Join our Discord!" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Benötigst du Hilfe oder hast du Fragen? Trete unserem Discord bei!" - } - ] - } - ] - }, - { - "id": "marketplace_grid_subscribe_button", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Notify me" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Benachrichtige mich" - } - ] - } - ] - }, - { - "id": "marketplace_grid_subscribe_could_not_subscribe", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "You are already getting notified." - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Du wirst bereits benachrichtigt." - } - ] - } - ] - }, - { - "id": "marketplace_grid_subscribe_description_first_part", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "We will let you know when we get" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Wir werden dich informieren, wenn wir" - } - ] - } - ] - }, - { - "id": "marketplace_grid_subscribe_description_last_part", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "some new results." - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "neue Ergebnisse haben." - } - ] - } - ] - }, - { - "id": "marketplace_grid_subscribe_error", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Something went wrong. Please try again later." - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Etwas ist schief gelaufen. Bitte versuche es später noch einmal." - } - ] - } - ] - }, - { - "id": "marketplace_grid_subscribe_no_email", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Please enter your email address." - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Gib bitte deine Email-Adresse ein." - } - ] - } - ] - }, - { - "id": "marketplace_grid_subscribe_placeholder", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Enter email..." - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Email eingeben..." - } - ] - } - ] - }, - { - "id": "marketplace_grid_subscribe_secondary_button", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Help us build the ecosystem" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Helfe uns, das Ökosystem aufzubauen" - } - ] - } - ] - }, - { - "id": "marketplace_grid_subscribe_success", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "You will be notified when this feature is available." - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Du wirst benachrichtigt, wenn diese Funktion verfügbar ist." - } - ] - } - ] - }, - { - "id": "marketplace_grid_subscribe_title", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "No results yet" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Noch keine Ergebnisse" - } - ] - } - ] - }, - { - "id": "marketplace_grid_subscribe_unvalid_email", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Please enter a valid email address." - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Bitte gib eine gültige E-Mail-Adresse ein." - } - ] - } - ] - }, - { - "id": "marketplace_grid_title_generic", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "All Products" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Alle Produkte" - } - ] - } - ] - }, - { - "id": "marketplace_grid_title_guides", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "All Guides" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Alle Guides" - } - ] - } - ] - }, - { - "id": "marketplace_header_build_on_inlang_button", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Build on inlang" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Baue auf inlang auf" - } - ] - } - ] - }, - { - "id": "marketplace_header_category_application", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Application" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Anwendung" - } - ] - } - ] - }, - { - "id": "marketplace_header_category_lint", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Lint Rules" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Lint Regeln" - } - ] - } - ] - }, - { - "id": "marketplace_header_category_markdown", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Markdown" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Markdown" - } - ] - } - ] - }, - { - "id": "marketplace_header_category_missing_something", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Missing something?" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Fehlt etwas?" - } - ] - } - ] - }, - { - "id": "marketplace_header_category_website", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Website" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Webseite" - } - ] - } - ] - }, - { - "id": "marketplace_header_search_placeholder", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Search" - } - ] - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Suche" - } - ] - } - ] - }, - { - "id": "newsletter_button", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Abschicken" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Subscribe" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Inscrever-se" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Prihlásiť sa" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "订阅" - } - ] - } - ] - }, - { - "id": "newsletter_error_alreadySubscribed", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Sie haben sich bereits für unseren Newsletter angemeldet." - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "You are already subscribed to our newsletter." - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Você já está inscrito em nossa newsletter." - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Už ste sa prihlásili na odber nášho informačného bulletinu." - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "你已经订阅了我们的通讯。" - } - ] - } - ] - }, - { - "id": "newsletter_error_emptyEmail", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Bitte geben Sie Ihre E-Mail Adresse ein." - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Please enter your email address." - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Por favor, insira seu endereço de e-mail." - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Zadajte svoju e-mailovú adresu." - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "请输入你的电子邮件地址。" - } - ] - } - ] - }, - { - "id": "newsletter_error_generic", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal." - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Something went wrong. Please try again later." - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Algo deu errado. Por favor, tente novamente mais tarde." - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Niečo sa pokazilo. Skúste to prosím neskôr." - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "出了点问题,请稍后再试。" - } - ] - } - ] - }, - { - "id": "newsletter_error_invalidEmail", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Bitte geben Sie eine gültige E-Mail Adresse ein." - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Please enter a valid email address." - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Por favor, insira um endereço de e-mail válido." - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Zadajte platnú e-mailovú adresu." - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "请输入有效的电子邮件地址。" - } - ] - } - ] - }, - { - "id": "newsletter_placeholder", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Eingabe der E-Mail ..." - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Enter your email ..." - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Digite o seu e-mail..." - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Zadajte email ..." - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "输入你的电子邮箱 ..." - } - ] - } - ] - }, - { - "id": "newsletter_subscribe_description", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Abonnieren Sie unseren Newsletter, um über die neuesten Entwicklungen auf dem Laufenden zu bleiben." - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "We'll send you updates about inlang and globalization. You can unsubscribe at any time." - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Vamos te enviar atualizações sobre o inlang e globalização. Você pode se desinscrever a qualquer momento." - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Budeme vám posielať aktuálne informácie o inlangu a globalizácii. Odber môžete kedykoľvek zrušiť." - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "我们将向你发送有关 inlang 和全球化的最新信息。你可以随时取消订阅。" - } - ] - } - ] - }, - { - "id": "newsletter_subscribe_title", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Bleiben Sie auf dem Laufenden" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Subscribe to our newsletter" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Receba as nossas novidades" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Prihláste sa na odber našich noviniek" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "订阅我们的通讯" - } - ] - } - ] - }, - { - "id": "newsletter_success", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Vielen Dank für Ihr Abonnement!" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Thank you for subscribing!" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Obrigado por se inscrever!" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Ďakujeme, že ste sa prihlásili na odber!" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "感谢你的订阅!" - } - ] - } - ] - }, - { - "id": "newsletter_title", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Newsletter" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "Newsletter" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Newsletter" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Newsletter" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "订阅邮件" - } - ] - } - ] - }, - { - "id": "newsletter_unsubscribed_description", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Sie haben sich erfolgreich von unserem Newsletter abgemeldet. Besuchen Sie uns bei Fragen auf" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "We're sad to see you go. If you have any feedback, please let us know on our" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Estamos tristes de ver você ir. Se você tiver qualquer feedback, nos informe em nosso" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Je nám ľúto, že odchádzate. Ak máte nejakú spätnú väzbu, dajte nám vedieť na našej" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "我们很舍不得你离开。如果你有任何反馈意见,请在我们的" - } - ] - } - ] - }, - { - "id": "newsletter_unsubscribed_title", - "selectors": [], - "variants": [ - { - "match": [], - "languageTag": "de", - "pattern": [ - { - "type": "Text", - "value": "Abmeldung erfolgreich" - } - ] - }, - { - "match": [], - "languageTag": "en", - "pattern": [ - { - "type": "Text", - "value": "You're unsubscribed" - } - ] - }, - { - "match": [], - "languageTag": "pt-BR", - "pattern": [ - { - "type": "Text", - "value": "Você se desinscreveu" - } - ] - }, - { - "match": [], - "languageTag": "sk", - "pattern": [ - { - "type": "Text", - "value": "Ste odhlásený z odberu" - } - ] - }, - { - "match": [], - "languageTag": "zh", - "pattern": [ - { - "type": "Text", - "value": "你已退订" - } - ] - } - ] - } - ] -} \ No newline at end of file diff --git a/inlang/source-code/editor/package.json b/inlang/source-code/editor/package.json index 7d40a16bad..787fe3a70d 100644 --- a/inlang/source-code/editor/package.json +++ b/inlang/source-code/editor/package.json @@ -58,6 +58,7 @@ "solid-js": "1.7.11", "solid-slider": "1.3.15", "solid-tiptap": "^0.6.0", + "throttle-debounce": "^5.0.0", "tsx": "3.14.0", "unist-util-visit": "5.0.0", "yaml": "^2.1.3", @@ -86,6 +87,7 @@ "@types/express": "^4.17.14", "@types/marked": "^4.0.8", "@types/node": "20.5.9", + "@types/throttle-debounce": "^5.0.2", "autoprefixer": "^10.4.12", "babel-preset-solid": "1.7.7", "eslint-plugin-solid": "0.13.0", diff --git a/inlang/source-code/editor/src/pages/@host/@owner/@repository/Message.tsx b/inlang/source-code/editor/src/pages/@host/@owner/@repository/Message.tsx index f124fa6f1b..7b1a41799e 100644 --- a/inlang/source-code/editor/src/pages/@host/@owner/@repository/Message.tsx +++ b/inlang/source-code/editor/src/pages/@host/@owner/@repository/Message.tsx @@ -113,6 +113,14 @@ export function Message(props: { id: string }) { > +

+ {message() && Object.keys(message()!.alias).length > 0 + ? message()!.alias[Object.keys(message()!.alias)[0]!] + : ""} +

0) { // add all changes - for (const file of filesWithUncommittedChanges) { - await args.repo.add({ filepath: file[0] }) - } + await args.repo.add({ filepath: filesWithUncommittedChanges.map((file) => file[0]) }) + // commit changes await args.repo.commit({ author: { diff --git a/inlang/source-code/editor/src/pages/@host/@owner/@repository/components/PatternEditor.tsx b/inlang/source-code/editor/src/pages/@host/@owner/@repository/components/PatternEditor.tsx index f8f63accbe..1bd142ed0a 100644 --- a/inlang/source-code/editor/src/pages/@host/@owner/@repository/components/PatternEditor.tsx +++ b/inlang/source-code/editor/src/pages/@host/@owner/@repository/components/PatternEditor.tsx @@ -22,6 +22,7 @@ import { type MessageLintReport, } from "@inlang/sdk" import Link from "#src/renderer/Link.jsx" +import { debounce } from "throttle-debounce" /** * The pattern editor is a component that allows the user to edit the pattern of a message. @@ -142,7 +143,9 @@ export function PatternEditor(props: { ) createEffect( - on(currentJSON, () => { + // debounce to improve performance when typing + // eslint-disable-next-line solid/reactivity + on(currentJSON, debounce(500, () => { if (JSON.stringify(currentJSON().content) !== JSON.stringify(previousContent())) { autoSave() setPreviousContent(currentJSON().content) @@ -162,7 +165,7 @@ export function PatternEditor(props: { }) } }) - ) + )) const autoSave = () => { let newMessage diff --git a/inlang/source-code/editor/src/pages/@host/@owner/@repository/helper/showFilteredMessage.ts b/inlang/source-code/editor/src/pages/@host/@owner/@repository/helper/showFilteredMessage.ts index b5f277211f..442fe4ca6e 100644 --- a/inlang/source-code/editor/src/pages/@host/@owner/@repository/helper/showFilteredMessage.ts +++ b/inlang/source-code/editor/src/pages/@host/@owner/@repository/helper/showFilteredMessage.ts @@ -49,7 +49,11 @@ export const showFilteredMessage = (message: Message | undefined) => { const filteredBySearch = searchLower.length === 0 || (message !== undefined && - (message.id.toLowerCase().includes(searchLower) || patternsLower.includes(searchLower))) + (message.id.toLowerCase().includes(searchLower) || + (typeof message.alias === "object" && + // TODO: #2346 review alias search logic not to include "default" key name + JSON.stringify(message.alias).toLowerCase().includes(searchLower)) || + patternsLower.includes(searchLower))) ? filteredById : false diff --git a/inlang/source-code/end-to-end-tests/inlang-nextjs/other-folder/backend.inlang/settings.json b/inlang/source-code/end-to-end-tests/inlang-nextjs/other-folder/backend.inlang/settings.json index 6cf480b311..309cf3264c 100644 --- a/inlang/source-code/end-to-end-tests/inlang-nextjs/other-folder/backend.inlang/settings.json +++ b/inlang/source-code/end-to-end-tests/inlang-nextjs/other-folder/backend.inlang/settings.json @@ -16,5 +16,8 @@ "second-page": "./../../app/i18n/locales/{languageTag}/second-page.json", "translation": "./../../app/i18n/locales/{languageTag}/translation.json" } + }, + "experimental": { + "aliases": true } } diff --git a/inlang/source-code/end-to-end-tests/inlang-nextjs/project.inlang/settings.json b/inlang/source-code/end-to-end-tests/inlang-nextjs/project.inlang/settings.json index 604ca5a86e..23fd5dff09 100644 --- a/inlang/source-code/end-to-end-tests/inlang-nextjs/project.inlang/settings.json +++ b/inlang/source-code/end-to-end-tests/inlang-nextjs/project.inlang/settings.json @@ -16,5 +16,8 @@ "second-page": "./app/i18n/locales/{languageTag}/second-page.json", "translation": "./app/i18n/locales/{languageTag}/translation.json" } + }, + "experimental": { + "aliases": true } } diff --git a/inlang/source-code/ide-extension/src/commands/editMessage.test.ts b/inlang/source-code/ide-extension/src/commands/editMessage.test.ts index 5ea2e73c12..5f331ef959 100644 --- a/inlang/source-code/ide-extension/src/commands/editMessage.test.ts +++ b/inlang/source-code/ide-extension/src/commands/editMessage.test.ts @@ -49,6 +49,7 @@ describe("editMessageCommand", () => { const mockLanguageTag = "en" const mockMessage: Message = { id: mockMessageId, + alias: {}, selectors: [], variants: [ { @@ -109,6 +110,7 @@ describe("editMessageCommand", () => { const mockLanguageTag = "en" const mockMessage: Message = { id: mockMessageId, + alias: {}, selectors: [], variants: [ { diff --git a/inlang/source-code/ide-extension/src/commands/extractMessage.ts b/inlang/source-code/ide-extension/src/commands/extractMessage.ts index 8a797720f2..fb0c526a54 100644 --- a/inlang/source-code/ide-extension/src/commands/extractMessage.ts +++ b/inlang/source-code/ide-extension/src/commands/extractMessage.ts @@ -80,6 +80,7 @@ export const extractMessageCommand = { const message: Message = { id: selectedExtractOption.messageId, + alias: {}, selectors: [], variants: [ { diff --git a/inlang/source-code/ide-extension/src/decorations/contextTooltip.ts b/inlang/source-code/ide-extension/src/decorations/contextTooltip.ts index 754c7a69ab..069fa7727a 100644 --- a/inlang/source-code/ide-extension/src/decorations/contextTooltip.ts +++ b/inlang/source-code/ide-extension/src/decorations/contextTooltip.ts @@ -32,9 +32,11 @@ export function contextTooltip( ReturnType >[number] ) { - const message = state().project.query.messages.get({ - where: { id: referenceMessage.messageId }, - }) + // resolve message from id or alias + const message = + state().project.query.messages.get({ + where: { id: referenceMessage.messageId }, + }) ?? state().project.query.messages.getByDefaultAlias(referenceMessage.messageId) if (!message) { return undefined // Return early if message is not found diff --git a/inlang/source-code/ide-extension/src/decorations/messagePreview.ts b/inlang/source-code/ide-extension/src/decorations/messagePreview.ts index ca0a4e8956..505b73eb24 100644 --- a/inlang/source-code/ide-extension/src/decorations/messagePreview.ts +++ b/inlang/source-code/ide-extension/src/decorations/messagePreview.ts @@ -46,9 +46,11 @@ export async function messagePreview(args: { context: vscode.ExtensionContext }) }) return messages.map(async (message) => { - const _message = state().project.query.messages.get({ - where: { id: message.messageId }, - }) + // resolve message from id or alias + const _message = + state().project.query.messages.get({ + where: { id: message.messageId }, + }) ?? state().project.query.messages.getByDefaultAlias(message.messageId) const preferredLanguageTag = (await getSetting("previewLanguageTag")) || "" const translationLanguageTag = preferredLanguageTag.length diff --git a/inlang/source-code/ide-extension/src/diagnostics/linterDiagnostics.ts b/inlang/source-code/ide-extension/src/diagnostics/linterDiagnostics.ts index 01f0691fef..21b678c7be 100644 --- a/inlang/source-code/ide-extension/src/diagnostics/linterDiagnostics.ts +++ b/inlang/source-code/ide-extension/src/diagnostics/linterDiagnostics.ts @@ -25,58 +25,66 @@ export async function linterDiagnostics(args: { context: vscode.ExtensionContext const diagnosticsIndex: Record> = {} for (const message of messages) { - state().project.query.messageLintReports.get.subscribe( - { - where: { - messageId: message.messageId, + // resolve message from id or alias + const _message = + state().project.query.messages.get({ + where: { id: message.messageId }, + }) ?? state().project.query.messages.getByDefaultAlias(message.messageId) + + if (_message) { + state().project.query.messageLintReports.get.subscribe( + { + where: { + messageId: _message.id, + }, }, - }, - (reports) => { - const diagnostics: vscode.Diagnostic[] = [] - - if (!reports) { - return - } - - for (const report of reports) { - const { level } = report - - const diagnosticRange = new vscode.Range( - new vscode.Position( - message.position.start.line - 1, - message.position.start.character - 1 - ), - new vscode.Position( - message.position.end.line - 1, - message.position.end.character - 1 + (reports) => { + const diagnostics: vscode.Diagnostic[] = [] + + if (!reports) { + return + } + + for (const report of reports) { + const { level } = report + + const diagnosticRange = new vscode.Range( + new vscode.Position( + message.position.start.line - 1, + message.position.start.character - 1 + ), + new vscode.Position( + message.position.end.line - 1, + message.position.end.character - 1 + ) ) - ) - // Get the lint message for the source language tag or fallback to "en" + // Get the lint message for the source language tag or fallback to "en" - const lintMessage = typeof report.body === "object" ? report.body.en : report.body + const lintMessage = typeof report.body === "object" ? report.body.en : report.body - const diagnostic = new vscode.Diagnostic( - diagnosticRange, - `[${message.messageId}] – ${lintMessage}`, - mapLintLevelToSeverity(level) + const diagnostic = new vscode.Diagnostic( + diagnosticRange, + `[${message.messageId}] – ${lintMessage}`, + mapLintLevelToSeverity(level) + ) + if (!diagnosticsIndex[message.messageId]) diagnosticsIndex[message.messageId] = {} + // eslint-disable-next-line + diagnosticsIndex[message.messageId]![getRangeIndex(diagnostic.range)] = diagnostics + diagnostics.push(diagnostic) + } + + if (reports.length === 0) { + diagnosticsIndex[message.messageId] = {} + } + + linterDiagnosticCollection.set( + activeTextEditor.document.uri, + flattenDiagnostics(diagnosticsIndex) ) - if (!diagnosticsIndex[message.messageId]) diagnosticsIndex[message.messageId] = {} - // eslint-disable-next-line - diagnosticsIndex[message.messageId]![getRangeIndex(diagnostic.range)] = diagnostics - diagnostics.push(diagnostic) - } - - if (reports.length === 0) { - diagnosticsIndex[message.messageId] = {} } - - linterDiagnosticCollection.set( - activeTextEditor.document.uri, - flattenDiagnostics(diagnosticsIndex) - ) - } - ) + ) + } } }) diff --git a/inlang/source-code/ide-extension/src/utilities/messages/messages.test.ts b/inlang/source-code/ide-extension/src/utilities/messages/messages.test.ts index facd2f4616..78d20610fc 100644 --- a/inlang/source-code/ide-extension/src/utilities/messages/messages.test.ts +++ b/inlang/source-code/ide-extension/src/utilities/messages/messages.test.ts @@ -49,6 +49,7 @@ vi.mock("../../configuration.js", () => ({ // Mock data const mockMessage: Message = { id: "testMessage", + alias: {}, selectors: [], variants: [ { diff --git a/inlang/source-code/ide-extension/src/utilities/messages/messages.ts b/inlang/source-code/ide-extension/src/utilities/messages/messages.ts index 4074f56eec..e37dd2d2c7 100644 --- a/inlang/source-code/ide-extension/src/utilities/messages/messages.ts +++ b/inlang/source-code/ide-extension/src/utilities/messages/messages.ts @@ -127,8 +127,12 @@ export function createMessageWebviewProvider(args: { ).flat() const highlightedMessages = matchedMessages + // resolve messages from id or alias .map((message) => { - return state().project.query.messages.get({ where: { id: message.messageId } }) + return ( + state().project.query.messages.get({ where: { id: message.messageId } }) ?? + state().project.query.messages.getByDefaultAlias(message.messageId) + ) }) .filter((message): message is Message => message !== undefined) const highlightedMessagesHtml = diff --git a/inlang/source-code/message-lint-rules/emptyPattern/src/emptyPattern.test.ts b/inlang/source-code/message-lint-rules/emptyPattern/src/emptyPattern.test.ts index 52bea440e2..6aecdbf4d5 100644 --- a/inlang/source-code/message-lint-rules/emptyPattern/src/emptyPattern.test.ts +++ b/inlang/source-code/message-lint-rules/emptyPattern/src/emptyPattern.test.ts @@ -6,6 +6,7 @@ import { lintSingleMessage } from "@inlang/sdk/lint" const message1: Message = { id: "1", + alias: {}, selectors: [], variants: [ { languageTag: "en", match: [], pattern: [{ type: "Text", value: "Inlang" }] }, diff --git a/inlang/source-code/message-lint-rules/identicalPattern/src/identicalPattern.test.ts b/inlang/source-code/message-lint-rules/identicalPattern/src/identicalPattern.test.ts index a499612c0d..969ef03ee3 100644 --- a/inlang/source-code/message-lint-rules/identicalPattern/src/identicalPattern.test.ts +++ b/inlang/source-code/message-lint-rules/identicalPattern/src/identicalPattern.test.ts @@ -6,6 +6,7 @@ import { identicalPatternRule } from "./identicalPattern.js" const message1: Message = { id: "1", + alias: {}, selectors: [], variants: [ { languageTag: "en", match: [], pattern: [{ type: "Text", value: "This is Inlang" }] }, diff --git a/inlang/source-code/message-lint-rules/messageWithoutSource/src/messageWithoutSource.test.ts b/inlang/source-code/message-lint-rules/messageWithoutSource/src/messageWithoutSource.test.ts index 700fb8f64b..2d19a9e46c 100644 --- a/inlang/source-code/message-lint-rules/messageWithoutSource/src/messageWithoutSource.test.ts +++ b/inlang/source-code/message-lint-rules/messageWithoutSource/src/messageWithoutSource.test.ts @@ -6,6 +6,7 @@ import { messageWithoutSourceRule } from "./messageWithoutSource.js" const message1: Message = { id: "1", + alias: {}, selectors: [], variants: [ { languageTag: "en", match: [], pattern: [] }, diff --git a/inlang/source-code/message-lint-rules/missingTranslation/src/missingTranslation.test.ts b/inlang/source-code/message-lint-rules/missingTranslation/src/missingTranslation.test.ts index 860b2a230b..8859fd59b8 100644 --- a/inlang/source-code/message-lint-rules/missingTranslation/src/missingTranslation.test.ts +++ b/inlang/source-code/message-lint-rules/missingTranslation/src/missingTranslation.test.ts @@ -6,6 +6,7 @@ import { lintSingleMessage } from "@inlang/sdk/lint" const message1: Message = { id: "1", + alias: {}, selectors: [], variants: [ { languageTag: "en", match: [], pattern: [{ type: "Text", value: "Inlang" }] }, diff --git a/inlang/source-code/message-lint-rules/snakeCaseId/src/snakeCaseId.test.ts b/inlang/source-code/message-lint-rules/snakeCaseId/src/snakeCaseId.test.ts index 6d25a9f913..db86512f0c 100644 --- a/inlang/source-code/message-lint-rules/snakeCaseId/src/snakeCaseId.test.ts +++ b/inlang/source-code/message-lint-rules/snakeCaseId/src/snakeCaseId.test.ts @@ -6,6 +6,7 @@ import { snakeCaseId } from "./snakeCaseId.js" const messageValid: Message = { id: "message_with_valid_id", + alias: {}, selectors: [], variants: [ { languageTag: "en", match: [], pattern: [] }, @@ -15,6 +16,7 @@ const messageValid: Message = { const messageInvalid: Message = { id: "messageWithInvalidId", + alias: {}, selectors: [], variants: [ { languageTag: "en", match: [], pattern: [] }, diff --git a/inlang/source-code/paraglide/paraglide-js/src/compiler/compile.test.ts b/inlang/source-code/paraglide/paraglide-js/src/compiler/compile.test.ts index 2776133ca5..4401e0b2cb 100644 --- a/inlang/source-code/paraglide/paraglide-js/src/compiler/compile.test.ts +++ b/inlang/source-code/paraglide/paraglide-js/src/compiler/compile.test.ts @@ -461,6 +461,7 @@ test("typesafety", async () => { const mockMessages: Message[] = [ { id: "missingInGerman", + alias: {}, selectors: [], variants: [ { @@ -472,6 +473,7 @@ const mockMessages: Message[] = [ }, { id: "onlyText", + alias: {}, selectors: [], variants: [ { @@ -495,6 +497,7 @@ const mockMessages: Message[] = [ }, { id: "oneParam", + alias: {}, selectors: [], variants: [ { @@ -519,6 +522,7 @@ const mockMessages: Message[] = [ }, { id: "multipleParams", + alias: {}, selectors: [], variants: [ { diff --git a/inlang/source-code/paraglide/paraglide-js/src/compiler/compileMessage.test.ts b/inlang/source-code/paraglide/paraglide-js/src/compiler/compileMessage.test.ts index 22797a647e..94a81e66fb 100644 --- a/inlang/source-code/paraglide/paraglide-js/src/compiler/compileMessage.test.ts +++ b/inlang/source-code/paraglide/paraglide-js/src/compiler/compileMessage.test.ts @@ -6,6 +6,7 @@ it("should throw if a message uses `-` because `-` are invalid JS function names compileMessage( { id: "message-with-invalid-js-variable-name", + alias: {}, selectors: [], variants: [], }, @@ -20,6 +21,7 @@ it("should throw an error if a message has multiple variants with the same langu compileMessage( { id: "duplicateLanguageTag", + alias: {}, selectors: [], variants: [ { @@ -44,6 +46,7 @@ it("should compile a message with a language tag that contains a hyphen - to an const result = compileMessage( { id: "login_button", + alias: {}, selectors: [], variants: [ { @@ -66,6 +69,7 @@ it("should compile a message to a function", async () => { const result = compileMessage( { id: "multipleParams", + alias: {}, selectors: [], variants: [ { @@ -141,6 +145,7 @@ it("should add a /* @__NO_SIDE_EFFECTS__ */ comment to the compiled message", as const result = compileMessage( { id: "some_message", + alias: {}, selectors: [], variants: [ { @@ -162,6 +167,7 @@ it("should re-export the message from a fallback language tag if the message is const result = compileMessage( { id: "some_message", + alias: {}, selectors: [], variants: [ { @@ -182,6 +188,7 @@ it("should return the message ID if no fallback can be found", async () => { const result = compileMessage( { id: "some_message", + alias: {}, selectors: [], variants: [ { @@ -197,3 +204,72 @@ it("should return the message ID if no fallback can be found", async () => { expect(result.en?.includes('export const some_message = () => "some_message"')).toBe(true) }) + +it("should inclide aliases for messages", async () => { + const result = compileMessage( + { + id: "some_message", + alias: { + default: "some_message_alias", + }, + selectors: [], + variants: [ + { + match: [], + languageTag: "en", + pattern: [{ type: "Text", value: "Etwas Text" }], + }, + ], + }, + ["en", "de"], + "en" + ) + + expect(result.en?.includes("export const some_message_alias")).toBe(true) +}) + +it("should inclide aliases for messages from a fallback language", async () => { + const result = compileMessage( + { + id: "some_message", + alias: { + default: "some_message_alias", + }, + selectors: [], + variants: [ + { + match: [], + languageTag: "de", + pattern: [{ type: "Text", value: "Etwas Text" }], + }, + ], + }, + ["en", "de"], + "de" + ) + + expect(result.en?.includes("some_message_alias")).toBe(true) +}) + +it("should inclide aliases for fallback messages", async () => { + const result = compileMessage( + { + id: "some_message", + alias: { + default: "some_message_alias", + }, + selectors: [], + variants: [ + { + match: [], + languageTag: "de", + pattern: [{ type: "Text", value: "Etwas Text" }], + }, + ], + }, + ["en", "de"], + "en" + ) + + expect(result.en?.includes("some_message_alias")).toBe(true) +}) diff --git a/inlang/source-code/paraglide/paraglide-js/src/compiler/compileMessage.ts b/inlang/source-code/paraglide/paraglide-js/src/compiler/compileMessage.ts index 5ef47c231b..ffa30bb578 100644 --- a/inlang/source-code/paraglide/paraglide-js/src/compiler/compileMessage.ts +++ b/inlang/source-code/paraglide/paraglide-js/src/compiler/compileMessage.ts @@ -85,12 +85,9 @@ export const compileMessage = ( const compiledFallbackPattern = compiledPatterns[fallbackLanguage] //if the fallback has the pattern, reexport the message from the fallback language - if (compiledFallbackPattern) { - resource[languageTag] = reexportMessage(message.id, fallbackLanguage) - } else { - //otherwise, fallback to the message ID - resource[languageTag] = messageIdFallback(message.id, languageTag) - } + resource[languageTag] = compiledFallbackPattern + ? reexportMessage(message, fallbackLanguage) + : messageIdFallback(message, languageTag) } } @@ -125,7 +122,9 @@ ${args.availableLanguageTags .map((tag) => `\t\t${isValidJSIdentifier(tag) ? tag : `"${tag}"`}: ${i(tag)}.${args.message.id}`) .join(",\n")} }[options.languageTag ?? languageTag()](${hasParams ? "params" : ""}) -}` +} +${reexportAliases(args.message)} +` } const messageFunction = (args: { @@ -137,23 +136,70 @@ const messageFunction = (args: { const hasParams = Object.keys(args.params).length > 0 return ` + /** * ${paramsType(args.params, false)} * @returns {string} */ /* @__NO_SIDE_EFFECTS__ */ -export const ${args.message.id} = (${hasParams ? "params" : ""}) => ${args.compiledPattern}` +export const ${args.message.id} = (${hasParams ? "params" : ""}) => ${args.compiledPattern} +${reexportAliases(args.message)} +` } -function reexportMessage(messageId: string, fromLanguageTag: string) { - return `export { ${messageId} } from "./${fromLanguageTag}.js"` +function reexportMessage(message: Message, fromLanguageTag: string) { + const exports: string[] = [message.id] + + if (message.alias["default"] && message.id !== message.alias["default"]) { + exports.push(message.alias["default"]) + } + + return `export { ${exports.join(", ")} } from "./${fromLanguageTag}.js"` } -function messageIdFallback(messageId: string, languageTag: string) { +function messageIdFallback(message: Message, languageTag: string) { return `/** -* Failed to resolve message ${messageId} for languageTag "${languageTag}". +* Failed to resolve message ${message.id} for languageTag "${languageTag}". * @returns {string} */ /* @__NO_SIDE_EFFECTS__ */ -export const ${messageId} = () => "${escapeForDoubleQuoteString(messageId)}"` +export const ${message.id} = () => "${escapeForDoubleQuoteString(message.id)}" +${reexportAliases(message)} +` +} + +/** + * Returns re-export statements for each alias of a message. + * If no aliases are present, this function returns an empty string. + * + * @param message + */ +function reexportAliases(message: Message) { + let code = "" + + if (message.alias["default"] && message.id !== message.alias["default"]) { + code += ` +/** + * Change the reference from the alias \`m.${message.alias["default"]}()\` to \`m.${message.id}()\`: + * \`\`\`diff + * - m.${message.alias["default"]}() + * + m.${message.id}() + * \`\`\` + * --- + * \`${message.alias["default"]}\` is an alias for the message \`${message.id}\`. + * Referencing aliases instead of the message ID has downsides like: + * + * - The alias might be renamed in the future, breaking the code. + * - Constant naming convention discussions. + * + * Read more about aliases and their downsides here + * @see inlang.com/link. + * --- + * @deprecated reference the message by id \`m.${message.id}()\` instead + */ +export const ${message.alias["default"]} = ${message.id}; +` + } + + return code } diff --git a/inlang/source-code/plugins/i18next/src/plugin.test.ts b/inlang/source-code/plugins/i18next/src/plugin.test.ts index e62aa400b1..6908055fdc 100644 --- a/inlang/source-code/plugins/i18next/src/plugin.test.ts +++ b/inlang/source-code/plugins/i18next/src/plugin.test.ts @@ -294,6 +294,7 @@ describe("saveMessage", () => { const messages: Message[] = [ { id: "test", + alias: {}, selectors: [], variants: [ { @@ -329,6 +330,7 @@ describe("saveMessage", () => { const messages: Message[] = [ { id: "common:test", + alias: {}, selectors: [], variants: [ { @@ -345,6 +347,7 @@ describe("saveMessage", () => { }, { id: "common:test2", + alias: {}, selectors: [], variants: [ { @@ -776,6 +779,7 @@ describe("formatting", () => { const reference: Message[] = [ { id: "common:test.", + alias: {}, selectors: [], variants: [ { @@ -792,6 +796,7 @@ describe("formatting", () => { }, { id: "common:test.test", + alias: {}, selectors: [], variants: [ { @@ -841,6 +846,7 @@ describe("formatting", () => { const reference: Message[] = [ { id: "common:a..b", + alias: {}, selectors: [], variants: [ { @@ -857,6 +863,7 @@ describe("formatting", () => { }, { id: "common:c.", + alias: {}, selectors: [], variants: [ { @@ -925,6 +932,7 @@ describe("formatting", () => { messages.push({ id: "test.test", + alias: {}, selectors: [], variants: [ { @@ -992,6 +1000,7 @@ describe("roundTrip", () => { } const newMessage: Message = { id: "test2", + alias: {}, selectors: [], variants: [variant], } @@ -1034,6 +1043,7 @@ describe("roundTrip", () => { const reference: Message[] = [ { id: "common:test", + alias: {}, selectors: [], variants: [ { diff --git a/inlang/source-code/plugins/i18next/src/plugin.ts b/inlang/source-code/plugins/i18next/src/plugin.ts index b0d9bdb6ea..8461926790 100644 --- a/inlang/source-code/plugins/i18next/src/plugin.ts +++ b/inlang/source-code/plugins/i18next/src/plugin.ts @@ -198,6 +198,7 @@ const addVariantToMessages = ( // message does not exist const message: Message = { id: key, + alias: {}, selectors: [], variants: [], } diff --git a/inlang/source-code/plugins/inlang-message-format/src/parsing/parseMessage.test.ts b/inlang/source-code/plugins/inlang-message-format/src/parsing/parseMessage.test.ts index 51a2d05941..535170bc0b 100644 --- a/inlang/source-code/plugins/inlang-message-format/src/parsing/parseMessage.test.ts +++ b/inlang/source-code/plugins/inlang-message-format/src/parsing/parseMessage.test.ts @@ -10,6 +10,7 @@ test("it parse a variable reference", async () => { expect(parsed).toStrictEqual({ id: "test", + alias: {}, selectors: [], variants: [ { diff --git a/inlang/source-code/plugins/inlang-message-format/src/parsing/parseMessage.ts b/inlang/source-code/plugins/inlang-message-format/src/parsing/parseMessage.ts index 311aa2104a..9f60a8b1b8 100644 --- a/inlang/source-code/plugins/inlang-message-format/src/parsing/parseMessage.ts +++ b/inlang/source-code/plugins/inlang-message-format/src/parsing/parseMessage.ts @@ -8,6 +8,7 @@ export const parseMessage = (args: { }): Message => { return { id: args.key, + alias: {}, selectors: [], variants: [ { diff --git a/inlang/source-code/plugins/inlang-message-format/src/parsing/serializeMessage.test.ts b/inlang/source-code/plugins/inlang-message-format/src/parsing/serializeMessage.test.ts index bc180b7df6..2c98cfa0c5 100644 --- a/inlang/source-code/plugins/inlang-message-format/src/parsing/serializeMessage.test.ts +++ b/inlang/source-code/plugins/inlang-message-format/src/parsing/serializeMessage.test.ts @@ -5,6 +5,7 @@ import { serializeMessage } from "./serializeMessage.js" test("it should split the variants into language tags", async () => { const message: Message = { id: "test", + alias: {}, selectors: [], variants: [ { match: [], languageTag: "en", pattern: [{ type: "Text", value: "Hello" }] }, @@ -21,6 +22,7 @@ test("it should split the variants into language tags", async () => { test("it should throw if there are multiple variants for the same language tag which is unsupported at the moment", async () => { const message: Message = { id: "test", + alias: {}, selectors: [], variants: [ { match: ["female"], languageTag: "en", pattern: [{ type: "Text", value: "Hello actress" }] }, diff --git a/inlang/source-code/plugins/json/src/plugin.test.ts b/inlang/source-code/plugins/json/src/plugin.test.ts index e9a97fcf28..ac2f7caddc 100644 --- a/inlang/source-code/plugins/json/src/plugin.test.ts +++ b/inlang/source-code/plugins/json/src/plugin.test.ts @@ -290,6 +290,7 @@ describe("saveMessage", () => { const messages: Message[] = [ { id: "test", + alias: {}, selectors: [], variants: [ { @@ -326,6 +327,7 @@ describe("saveMessage", () => { const messages: Message[] = [ { id: "common:test", + alias: {}, selectors: [], variants: [ { @@ -342,6 +344,7 @@ describe("saveMessage", () => { }, { id: "common:test2", + alias: {}, selectors: [], variants: [ { @@ -561,6 +564,7 @@ describe("formatting", () => { const reference: Message[] = [ { id: "common:test.", + alias: {}, selectors: [], variants: [ { @@ -577,6 +581,7 @@ describe("formatting", () => { }, { id: "common:test.test", + alias: {}, selectors: [], variants: [ { @@ -626,6 +631,7 @@ describe("formatting", () => { const reference: Message[] = [ { id: "common:a..b", + alias: {}, selectors: [], variants: [ { @@ -642,6 +648,7 @@ describe("formatting", () => { }, { id: "common:c.", + alias: {}, selectors: [], variants: [ { @@ -747,6 +754,7 @@ describe("formatting", () => { messages.push({ id: "test.test", + alias: {}, selectors: [], variants: [ { @@ -814,6 +822,7 @@ describe("roundTrip", () => { } const newMessage: Message = { id: "test2", + alias: {}, selectors: [], variants: [variant], } @@ -856,6 +865,7 @@ describe("roundTrip", () => { const reference: Message[] = [ { id: "common:test", + alias: {}, selectors: [], variants: [ { diff --git a/inlang/source-code/plugins/json/src/plugin.ts b/inlang/source-code/plugins/json/src/plugin.ts index 56b629ee10..2133d07a2e 100644 --- a/inlang/source-code/plugins/json/src/plugin.ts +++ b/inlang/source-code/plugins/json/src/plugin.ts @@ -175,6 +175,7 @@ const addVariantToMessages = ( // message does not exist const message: Message = { id: key, + alias: {}, selectors: [], variants: [], } diff --git a/inlang/source-code/plugins/next-intl/src/plugin.test.ts b/inlang/source-code/plugins/next-intl/src/plugin.test.ts index 604d4fd3e1..69c9dd7add 100644 --- a/inlang/source-code/plugins/next-intl/src/plugin.test.ts +++ b/inlang/source-code/plugins/next-intl/src/plugin.test.ts @@ -104,6 +104,7 @@ describe("saveMessage", () => { const messages: Message[] = [ { id: "test", + alias: {}, selectors: [], variants: [ { @@ -431,6 +432,7 @@ describe("formatting", () => { const reference: Message[] = [ { id: "test.", + alias: {}, selectors: [], variants: [ { @@ -447,6 +449,7 @@ describe("formatting", () => { }, { id: "test.test", + alias: {}, selectors: [], variants: [ { @@ -494,6 +497,7 @@ describe("formatting", () => { const reference: Message[] = [ { id: "a..b", + alias: {}, selectors: [], variants: [ { @@ -510,6 +514,7 @@ describe("formatting", () => { }, { id: "c.", + alias: {}, selectors: [], variants: [ { @@ -578,6 +583,7 @@ describe("formatting", () => { messages.push({ id: "test.test", + alias: {}, selectors: [], variants: [ { @@ -645,6 +651,7 @@ describe("roundTrip", () => { } const newMessage: Message = { id: "test2", + alias: {}, selectors: [], variants: [variant], } diff --git a/inlang/source-code/plugins/next-intl/src/plugin.ts b/inlang/source-code/plugins/next-intl/src/plugin.ts index d985e54474..5400759ace 100644 --- a/inlang/source-code/plugins/next-intl/src/plugin.ts +++ b/inlang/source-code/plugins/next-intl/src/plugin.ts @@ -177,6 +177,7 @@ const addVariantToMessages = ( // message does not exist const message: Message = { id: key, + alias: {}, selectors: [], variants: [], } diff --git a/inlang/source-code/rpc/src/functions/machineTranslateMessage.test.ts b/inlang/source-code/rpc/src/functions/machineTranslateMessage.test.ts index 770ceea01a..90dc06f49f 100644 --- a/inlang/source-code/rpc/src/functions/machineTranslateMessage.test.ts +++ b/inlang/source-code/rpc/src/functions/machineTranslateMessage.test.ts @@ -11,6 +11,7 @@ it.runIf(privateEnv.GOOGLE_TRANSLATE_API_KEY)( targetLanguageTags: ["de", "fr"], message: { id: "mockMessage", + alias: {}, selectors: [], variants: [ { languageTag: "en", match: [], pattern: [{ type: "Text", value: "Hello world" }] }, @@ -20,6 +21,7 @@ it.runIf(privateEnv.GOOGLE_TRANSLATE_API_KEY)( expect(result.error).toBeUndefined() expect(result.data).toEqual({ id: "mockMessage", + alias: {}, selectors: [], variants: [ { languageTag: "en", match: [], pattern: [{ type: "Text", value: "Hello world" }] }, @@ -38,6 +40,7 @@ it.runIf(privateEnv.GOOGLE_TRANSLATE_API_KEY)( targetLanguageTags: ["de"], message: { id: "mockMessage", + alias: {}, selectors: [], variants: [ { languageTag: "en", match: [], pattern: [{ type: "Text", value: "Good evening" }] }, @@ -57,6 +60,7 @@ it.runIf(privateEnv.GOOGLE_TRANSLATE_API_KEY)( expect(result.error).toBeUndefined() expect(result.data).toEqual({ id: "mockMessage", + alias: {}, selectors: [], variants: [ { languageTag: "en", match: [], pattern: [{ type: "Text", value: "Good evening" }] }, @@ -92,6 +96,7 @@ it.todo("should not naively compare the variant lenghts and instead match varian targetLanguageTags: ["de"], message: { id: "mockMessage", + alias: {}, selectors: [ { type: "VariableReference", @@ -115,6 +120,7 @@ it.todo("should not naively compare the variant lenghts and instead match varian expect(result.error).toBeUndefined() expect(result.data).toEqual({ id: "mockMessage", + alias: {}, selectors: [ { type: "VariableReference", @@ -149,6 +155,7 @@ it.runIf(privateEnv.GOOGLE_TRANSLATE_API_KEY)( targetLanguageTags: ["de"], message: { id: "mockMessage", + alias: {}, selectors: [], variants: [ { @@ -166,6 +173,7 @@ it.runIf(privateEnv.GOOGLE_TRANSLATE_API_KEY)( expect(result.error).toBeUndefined() expect(result.data).toEqual({ id: "mockMessage", + alias: {}, selectors: [], variants: [ { @@ -199,6 +207,7 @@ it.runIf(privateEnv.GOOGLE_TRANSLATE_API_KEY)( targetLanguageTags: ["de"], message: { id: "mockMessage", + alias: {}, selectors: [], variants: [ { @@ -217,6 +226,7 @@ it.runIf(privateEnv.GOOGLE_TRANSLATE_API_KEY)( expect(result.error).toBeUndefined() expect(result.data).toEqual({ id: "mockMessage", + alias: {}, selectors: [], variants: [ { diff --git a/inlang/source-code/rpc/src/functions/machineTranslateMessage.ts b/inlang/source-code/rpc/src/functions/machineTranslateMessage.ts index 8de5dad47b..1424be1e0b 100644 --- a/inlang/source-code/rpc/src/functions/machineTranslateMessage.ts +++ b/inlang/source-code/rpc/src/functions/machineTranslateMessage.ts @@ -34,23 +34,37 @@ Promise> { continue } const placeholderMetadata: PlaceholderMetadata = {} - const response = await fetch( - "https://translation.googleapis.com/language/translate/v2?" + - new URLSearchParams({ - q: serializePattern(variant.pattern, placeholderMetadata), - target: targetLanguageTag, - source: args.sourceLanguageTag, - // html to escape placeholders - format: "html", - key: privateEnv.GOOGLE_TRANSLATE_API_KEY, - }), - { method: "POST" } - ) - if (!response.ok) { - return { error: response.statusText } + const q = serializePattern(variant.pattern, placeholderMetadata) + let translation: string + + if (!process.env.MOCK_TRANSLATE) { + const response = await fetch( + "https://translation.googleapis.com/language/translate/v2?" + + new URLSearchParams({ + q, + target: targetLanguageTag, + source: args.sourceLanguageTag, + // html to escape placeholders + format: "html", + key: privateEnv.GOOGLE_TRANSLATE_API_KEY, + }), + { method: "POST" } + ) + if (!response.ok) { + const err = `${response.status} ${response.statusText}: translating from ${args.sourceLanguageTag} to ${targetLanguageTag}` + return { error: err } + } + const json = await response.json() + translation = json.data.translations[0].translatedText + } else { + const mockTranslation = await mockTranslateApi( + q, + args.sourceLanguageTag, + targetLanguageTag + ) + if (mockTranslation.error) return { error: mockTranslation.error } + translation = mockTranslation.translation } - const json = await response.json() - const translation = json.data.translations[0].translatedText copy.variants.push({ languageTag: targetLanguageTag, match: variant.match, @@ -65,6 +79,52 @@ Promise> { } } +// MOCK_TRANSLATE: Mock the google translate api +const mockTranslate = !!process.env.MOCK_TRANSLATE + +// MOCK_TRANSLATE_ERRORS: 0 = no errors (default), 1 = all errors, n > 1 = 1/n fraction of errors +const mockErrors = Math.ceil(Number(process.env.MOCK_TRANSLATE_ERRORS)) || 0 + +// MOCK_TRANSLATE_LATENCY in ms (default 0) +const mockLatency = Number(process.env.MOCK_TRANSLATE_LATENCY) || 0 + +if (mockTranslate) { + const errors = mockErrors === 0 ? "no" : mockErrors === 1 ? "all" : `1/${mockErrors}` + // eslint-disable-next-line no-console + console.log(`🥸 Mocking machine translate api with ${errors} errors, ${mockLatency}ms latency`) +} + +// Keep track of the number mock of calls, so we can simulate errors +let mockCount = 0 + +/** + * Mock the google translate api with a delay. + * Enable by setting MOCK_TRANSLATE to true. + * + * Optionally set + * - MOCK_TRANSLATE_LATENCY to simulate latency: default=0 (ms), + * - MOCK_TRANSLATE_ERRORS to simulate errors: default=0. + * - 0: no errors + * - 1: only errors + * - n > 1: 1/n fraction of errors + */ +async function mockTranslateApi( + q: string, + sourceLanguageTag: string, + targetLanguageTag: string +): Promise<{ translation: string; error?: string }> { + mockCount++ + const error = mockCount % mockErrors === 0 ? "Mock error" : undefined + const prefix = `Mock translate ${sourceLanguageTag} to ${targetLanguageTag}: ` + // eslint-disable-next-line no-console + // console.log(`${error ? "💥 Error " : ""}${prefix}${q.length > 50 ? q.slice(0, 50) + "..." : q}`) + await new Promise((resolve) => setTimeout(resolve, mockLatency)) + return { + translation: prefix + q, + error, + } +} + /** * Thanks to https://issuetracker.google.com/issues/119256504?pli=1 this crap is required. * diff --git a/inlang/source-code/sdk/.eslintrc.json b/inlang/source-code/sdk/.eslintrc.json index 6c2e3f01fa..2b51924587 100644 --- a/inlang/source-code/sdk/.eslintrc.json +++ b/inlang/source-code/sdk/.eslintrc.json @@ -1,17 +1,4 @@ { - "rules": { - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": ["node:*"], - "message": "Keep in mind that node API's don't work inside the browser" - } - ] - } - ] - }, "overrides": [ { "files": ["**/*.ts"], diff --git a/inlang/source-code/sdk/load-test/.gitignore b/inlang/source-code/sdk/load-test/.gitignore new file mode 100644 index 0000000000..843acd3231 --- /dev/null +++ b/inlang/source-code/sdk/load-test/.gitignore @@ -0,0 +1,2 @@ +node_modules +locales \ No newline at end of file diff --git a/inlang/source-code/sdk/load-test/.vscode/extensions.json b/inlang/source-code/sdk/load-test/.vscode/extensions.json new file mode 100644 index 0000000000..116d6852a6 --- /dev/null +++ b/inlang/source-code/sdk/load-test/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "inlang.vs-code-extension" + ] +} \ No newline at end of file diff --git a/inlang/source-code/sdk/load-test/README.md b/inlang/source-code/sdk/load-test/README.md new file mode 100644 index 0000000000..76b21e3eeb --- /dev/null +++ b/inlang/source-code/sdk/load-test/README.md @@ -0,0 +1,57 @@ +# inlang sdk load-test + +This repo can be used for volume testing, with more messages than existing unit tests. + +- The test starts by opening an inlang project with just one english message. +- It generates additional engish messages, overwriting ./locales/en/common.json. +- It can "mock-translate" those into 37 preconfigured languages using the inlang cli. +- Lint-rule plugins are configured in the project settings but lint reports are not subscribed, unless requested. +- The test uses the i18next message storage plugin. + +To allow additional testing on the generated project e.g. with the ide-extension, the test calls `pnpm clean` when it starts, but not after it runs. + +``` +USAGE: + pnpm test messageCount [translate] [subscribeToMessages] [subscribeToLintReports] [watchMode] +e.g. + pnpm test 300 + pnpm test 100 1 1 0 + +Defaults: translate: 1, subscribeToMessages: 1, subscribeToLintReports: 0, watchMode: 0 +``` + +### mock rpc server +This test expects the rpc server from PR [#2108](https://github.com/opral/monorepo/pull/2108) running on localhost:3000 with MOCK_TRANSLATE=true. + +```sh +# in your opral/monorepo +git checkout 1844-sdk-persistence-of-messages-in-project-direcory +pnpm install +pnpm build +MOCK_TRANSLATE=true pnpm --filter @inlang/server dev +``` + +### install +```sh +git clone https://github.com/opral/load-test.git +cd load-test +pnpm install +``` +This test is also available under /inlang/source-code/sdk/load-test in the monorepo, using workspace:* dependencies. + +### run +```sh +pnpm test messageCount [translate] [subscribeToMessages] [subscribeToLintReports] [watchMode] +``` + +### clean +Called before each test run, does `rm -rf ./locales`. +```sh +pnpm clean +``` + +### debug in chrome dev tools with node inspector +Passes --inpect-brk to node. +```sh +pnpm inspect messageCount [translate] [subscribeToMessages] [subscribeToLintReports] [watchMode] +``` diff --git a/inlang/source-code/sdk/load-test/load-test.ts b/inlang/source-code/sdk/load-test/load-test.ts new file mode 100644 index 0000000000..d6dbd814ea --- /dev/null +++ b/inlang/source-code/sdk/load-test/load-test.ts @@ -0,0 +1,148 @@ +/* eslint-disable no-restricted-imports */ +/* eslint-disable no-console */ +import { openRepository } from "@lix-js/client" +import { loadProject } from "@inlang/sdk" + +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" +import { throttle } from "throttle-debounce" +import childProcess from "node:child_process" +import fs from "node:fs/promises" + +import _debug from "debug" +const debug = _debug("load-test") + +const exec = promisify(childProcess.exec) + +const throttleEventLogs = 2000 + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const projectPath = join(__dirname, "project.inlang") + +const mockServer = "http://localhost:3000" + +const cli = `PUBLIC_SERVER_BASE_URL=${mockServer} pnpm inlang` +const translateCommand = cli + " machine translate -f --project ./project.inlang" + +const messageDir = join(__dirname, "locales", "en") +const messageFile = join(__dirname, "locales", "en", "common.json") + +export async function runLoadTest( + messageCount: number = 1000, + translate: boolean = true, + subscribeToMessages: boolean = true, + subscribeToLintReports: boolean = false, + watchMode: boolean = false +) { + debug("load-test start" + (watchMode ? " - watchMode on, ctrl C to exit" : "")) + + if (translate && !(await isMockRpcServerRunning())) { + console.error( + `Please start the mock rpc server with "MOCK_TRANSLATE=true pnpm --filter @inlang/server dev"` + ) + return + } + + process.on("SIGINT", () => { + debug("bye bye") + process.exit(0) + }) + + await generateMessageFile(1) + + debug("opening repo and loading project") + const repo = await openRepository(__dirname, { nodeishFs: fs }) + const project = await loadProject({ repo, projectPath }) + + debug("subscribing to project.errors") + project.errors.subscribe((errors) => { + if (errors.length > 0) { + debug(`load=test project errors ${errors[0]}`) + } + }) + + if (subscribeToMessages) { + debug("subscribing to messages.getAll") + let messagesEvents = 0 + const logMessagesEvent = throttle(throttleEventLogs, (messages: any) => { + debug(`messages changed event: ${messagesEvents}, length: ${messages.length}`) + }) + project.query.messages.getAll.subscribe((messages) => { + messagesEvents++ + logMessagesEvent(messages) + }) + } + + if (subscribeToLintReports) { + debug("subscribing to lintReports.getAll") + let lintEvents = 0 + const logLintEvent = throttle(throttleEventLogs, (reports: any) => { + debug(`lint reports changed event: ${lintEvents}, length: ${reports.length}`) + }) + project.query.messageLintReports.getAll.subscribe((reports) => { + lintEvents++ + logLintEvent(reports) + }) + } + + debug(`generating ${messageCount} messages`) + await generateMessageFile(messageCount) + + if (translate) { + debug("translating messages with inlang cli") + await exec(translateCommand, { cwd: __dirname }) + } + + debug("load-test done - " + (watchMode ? "watching for events" : "exiting")) + + if (watchMode) { + await new Promise((resolve) => { + setTimeout(resolve, 1000 * 60 * 60 * 24) + }) + } +} + +async function generateMessageFile(messageCount: number) { + await exec(`mkdir -p ${messageDir}`) + const messages: Record = {} + for (let i = 1; i <= messageCount; i++) { + messages[`message_key_${i}`] = `Generated message (${i})` + } + await fs.writeFile(messageFile, JSON.stringify(messages, undefined, 2), "utf-8") +} + +async function isMockRpcServerRunning(): Promise { + try { + const req = await fetch(`${mockServer}/ping`) + if (!req.ok) { + console.error(`Mock rpc server responded with status: ${req.status}`) + return false + } + const res = await req.text() + const expected = `${mockServer} MOCK_TRANSLATE\n` + if (res !== expected) { + console.error( + `Mock rpc server responded with: ${JSON.stringify(res)} instead of ${JSON.stringify( + expected + )}` + ) + return false + } + } catch (error) { + console.error(`Mock rpc server error: ${error} ${causeString(error)}`) + return false + } + return true +} + +function causeString(error: any) { + if (typeof error === "object" && error.cause) { + if (error.cause.errors?.length) return error.cause.errors.join(", ") + if (error.cause.code) return "" + error.cause.code + return JSON.stringify(error.cause) + } + return "" +} \ No newline at end of file diff --git a/inlang/source-code/sdk/load-test/main.ts b/inlang/source-code/sdk/load-test/main.ts new file mode 100644 index 0000000000..d1a363670d --- /dev/null +++ b/inlang/source-code/sdk/load-test/main.ts @@ -0,0 +1,26 @@ +/* eslint-disable no-console */ +import { runLoadTest } from "./load-test.js" + +const usage = ` +USAGE: + pnpm test messageCount [translate] [subscribeToMessages] [subscribeToLintReports] [watchMode] +e.g. + pnpm test 300 + pnpm test 100 1 1 0 + +Defaults: translate: 1, subscribeToMessages: 1, subscribeToLintReports: 0, watchMode: 0 +` + +if (numArg(2)) { + await runLoadTest(numArg(2), boolArg(3), boolArg(4), boolArg(5), boolArg(6)) +} else { + console.log(usage) +} + +function numArg(n: number) { + return Number(process.argv[n]) +} + +function boolArg(n: number) { + return isNaN(numArg(n)) ? undefined : !!numArg(n) +} diff --git a/inlang/source-code/sdk/load-test/package.json b/inlang/source-code/sdk/load-test/package.json new file mode 100644 index 0000000000..9acc0d1c1a --- /dev/null +++ b/inlang/source-code/sdk/load-test/package.json @@ -0,0 +1,31 @@ +{ + "name": "@inlang/sdk-load-test", + "private": true, + "type": "module", + "license": "Apache-2.0", + "dependencies": { + "@inlang/cli": "workspace:*", + "@inlang/sdk": "workspace:*", + "@lix-js/client": "workspace:*", + "debug": "^4.3.4", + "i18next": "^23.10.0", + "throttle-debounce": "^5.0.0" + }, + "devDependencies": { + "@types/debug": "^4.1.12", + "@types/node": "^20.11.20", + "@types/throttle-debounce": "5.0.0", + "tsx": "^4.7.1" + }, + "scripts": { + "clean": "rm -rf ./locales", + "translate": "PUBLIC_SERVER_BASE_URL=http://localhost:3000 pnpm inlang machine translate -f --project ./project.inlang", + "test": "pnpm clean && DEBUG=$DEBUG,load-test tsx ./main.ts", + "inspect": "pnpm clean && DEBUG=$DEBUG,load-test tsx --inspect-brk ./main.ts" + }, + "prettier": { + "semi": false, + "useTabs": true, + "printWidth": 100 + } +} diff --git a/inlang/source-code/sdk/load-test/project.inlang/project_id b/inlang/source-code/sdk/load-test/project.inlang/project_id new file mode 100644 index 0000000000..543ed029a3 --- /dev/null +++ b/inlang/source-code/sdk/load-test/project.inlang/project_id @@ -0,0 +1 @@ +6ec62e2e32830230234fa79c3bfda52b0c1b2be050c3be95970bc62f78ac16a9 \ No newline at end of file diff --git a/inlang/source-code/sdk/load-test/project.inlang/settings.json b/inlang/source-code/sdk/load-test/project.inlang/settings.json new file mode 100644 index 0000000000..113bc302ca --- /dev/null +++ b/inlang/source-code/sdk/load-test/project.inlang/settings.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "sourceLanguageTag": "en", + "languageTags": [ + "ar", + "az", + "bg", + "ca", + "cs", + "da", + "de", + "el", + "en", + "es-419", + "es", + "eu", + "fr", + "he", + "hr", + "hu", + "id", + "it", + "iw", + "ja", + "ko", + "nl", + "no", + "pl", + "pt-BR", + "pt", + "ro", + "ru", + "sk", + "sr", + "sv", + "ta", + "tr", + "uk", + "vi", + "zh-CN", + "zh-TW" + ], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-i18next/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@1/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-without-source@1/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@1/dist/index.js" + ], + "plugin.inlang.i18next": { + "pathPattern": "./locales/{languageTag}/common.json" + }, + "experimental": { + "aliases": true + } +} diff --git a/inlang/source-code/sdk/load-test/tsconfig.json b/inlang/source-code/sdk/load-test/tsconfig.json new file mode 100644 index 0000000000..4e09ec8825 --- /dev/null +++ b/inlang/source-code/sdk/load-test/tsconfig.json @@ -0,0 +1,42 @@ +/** + * Default TypeScript config for the inlang project. + * + * The goal of this config is strict ESM compilation across the inlang project. + * See https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c#how-can-i-make-my-typescript-project-output-esm + */ +{ + "compilerOptions": { + // Set the compiler to ESM (node16) + "module": "Node16", + // Resolve modules with ESM (node16) + "moduleResolution": "Node16", + // To provide backwards compatibility, Node.js allows you to import most CommonJS packages with a default import. This flag tells TypeScript that it's okay to use import on CommonJS modules. + "allowSyntheticDefaultImports": true, + // ESM doesn't support JSON modules yet. + "resolveJsonModule": false, + // Create type declaration files (otherwise no typesafety for the importing module) + "declaration": true, + // Strict type checking + "strict": true, + // forcing consistens casing in files is a life saver (different environments deal with file casing differently) + "forceConsistentCasingInFileNames": true, + // Don't check imported libraries for type errors. + // The imported libraries might have different settings/whatever. + "skipLibCheck": true, + "skipDefaultLibCheck": true, + // Lint JS files + "checkJs": true, + // Distingish between type imports and code imports. + "verbatimModuleSyntax": true, + // tremendous helper to avoid hard to debug bugs. + // see https://github.com/opral/monorepo/issues/157 + "noUncheckedIndexedAccess": true, + // enables better DX https://twitter.com/kuizinas/status/1636641120477384705?s=20 + "declarationMap": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "allowUnreachableCode": false + } +} diff --git a/inlang/source-code/sdk/package.json b/inlang/source-code/sdk/package.json index 2796fa15e5..9e6e3c9e59 100644 --- a/inlang/source-code/sdk/package.json +++ b/inlang/source-code/sdk/package.json @@ -38,15 +38,19 @@ "@inlang/project-settings": "workspace:*", "@inlang/result": "workspace:*", "@inlang/translatable": "workspace:*", + "@lix-js/client": "workspace:*", + "@lix-js/fs": "workspace:*", "@sinclair/typebox": "^0.31.17", + "debug": "^4.3.4", "dedent": "1.5.1", "deepmerge-ts": "^5.1.0", + "murmurhash3js": "^3.0.1", "solid-js": "1.6.12", - "throttle-debounce": "^5.0.0", - "@lix-js/fs": "workspace:*", - "@lix-js/client": "workspace:*" + "throttle-debounce": "^5.0.0" }, "devDependencies": { + "@types/debug": "^4.1.12", + "@types/murmurhash3js": "^3.0.7", "@types/throttle-debounce": "5.0.0", "@vitest/coverage-v8": "^0.33.0", "jsdom": "22.1.0", diff --git a/inlang/source-code/sdk/src/adapter/solidAdapter.test.ts b/inlang/source-code/sdk/src/adapter/solidAdapter.test.ts index c1b8ace442..15d2560fe5 100644 --- a/inlang/source-code/sdk/src/adapter/solidAdapter.test.ts +++ b/inlang/source-code/sdk/src/adapter/solidAdapter.test.ts @@ -28,6 +28,11 @@ const config: ProjectSettings = { }, } +const configWithAliases: ProjectSettings = { + ...config, + experimental: { aliases: true }, +} + const mockPlugin: Plugin = { id: "plugin.project.i18next", description: { en: "Mock plugin description" }, @@ -41,6 +46,7 @@ const mockPlugin: Plugin = { const exampleMessages: Message[] = [ { id: "a", + alias: {}, selectors: [], variants: [ { @@ -57,6 +63,7 @@ const exampleMessages: Message[] = [ }, { id: "b", + alias: {}, selectors: [], variants: [ { @@ -202,10 +209,10 @@ describe("messages", () => { { from } ) - let counter = 0 + let effectOnMessagesCounter = 0 createEffect(() => { project.query.messages.getAll() - counter += 1 + effectOnMessagesCounter += 1 }) expect(Object.values(project.query.messages.getAll()).length).toBe(2) @@ -215,11 +222,11 @@ describe("messages", () => { // TODO: how can we await `setConfig` correctly await new Promise((resolve) => setTimeout(resolve, 510)) - expect(counter).toBe(1) // 2 times because effect creation + set + expect(effectOnMessagesCounter).toBe(1) // 2 times because effect creation + set expect(Object.values(project.query.messages.getAll()).length).toBe(2) }) - it("should react to changes in messages", async () => { + it("should react to message udpate", async () => { const repo = await mockRepo() const fs = repo.nodeishFs await fs.mkdir("/user/project.inlang.inlang", { recursive: true }) @@ -268,6 +275,71 @@ describe("messages", () => { }, }) + it("should react to message udpate (with aliases)", async () => { + const repo = await mockRepo() + const fs = repo.nodeishFs + await fs.mkdir("/user/project.inlang.inlang", { recursive: true }) + await fs.writeFile( + "/user/project.inlang.inlang/settings.json", + JSON.stringify(configWithAliases) + ) + const project = solidAdapter( + await loadProject({ + projectPath: "/user/project.inlang.inlang", + repo, + _import: $import, + }), + { from } + ) + + let counter = 0 + createEffect(() => { + project.query.messages.getAll() + counter += 1 + }) + + const messagesBefore = project.query.messages.getAll + expect(Object.values(messagesBefore()).length).toBe(2) + expect( + ( + Object.values(messagesBefore())[0]?.variants.find( + (variant) => variant.languageTag === "en" + )?.pattern[0] as Text + ).value + ).toBe("test") + + project.query.messages.update({ + where: { id: "raw_tapir_pause_grateful" }, + // TODO: use `createMessage` utility + data: { + ...exampleMessages[0], + variants: [ + { + languageTag: "en", + match: [], + pattern: [ + { + type: "Text", + value: "test2", + }, + ], + }, + ], + }, + }) + + expect(counter).toBe(2) // 2 times because effect creation + set + const messagesAfter = project.query.messages.getAll + expect(Object.values(messagesAfter()).length).toBe(2) + expect( + ( + Object.values(messagesAfter())[0]?.variants.find( + (variant) => variant.languageTag === "en" + )?.pattern[0] as Text + ).value + ).toBe("test2") + }) + expect(counter).toBe(2) // 2 times because effect creation + set const messagesAfter = project.query.messages.getAll expect(Object.values(messagesAfter()).length).toBe(2) diff --git a/inlang/source-code/sdk/src/api.ts b/inlang/source-code/sdk/src/api.ts index 8ea1fd06b6..c4213faf48 100644 --- a/inlang/source-code/sdk/src/api.ts +++ b/inlang/source-code/sdk/src/api.ts @@ -72,6 +72,10 @@ export type MessageQueryApi = { callback: (message: Message) => void ) => void } + // use getByDefaultAlias() to resolve a message from its alias, not subscribable + getByDefaultAlias: ((alias: Message["alias"]["default"]) => Readonly) & { + subscribe: (alias: Message["alias"]["default"], callback: (message: Message) => void) => void + } includedMessageIds: Subscribable /* * getAll is depricated do not use it diff --git a/inlang/source-code/sdk/src/createMessageLintReportsQuery.ts b/inlang/source-code/sdk/src/createMessageLintReportsQuery.ts index a19fd0e6f2..ae19aeeed8 100644 --- a/inlang/source-code/sdk/src/createMessageLintReportsQuery.ts +++ b/inlang/source-code/sdk/src/createMessageLintReportsQuery.ts @@ -10,8 +10,7 @@ import type { resolveModules } from "./resolve-modules/index.js" import type { MessageLintReport, Message } from "./versionedInterfaces.js" import { lintSingleMessage } from "./lint/index.js" import { ReactiveMap } from "./reactivity/map.js" -import { debounce } from "throttle-debounce" -import { createEffect } from "./reactivity/solid.js" +import { createRoot, createEffect } from "./reactivity/solid.js" /** * Creates a reactive query API for messages. @@ -21,7 +20,6 @@ export function createMessageLintReportsQuery( settings: () => ProjectSettings, installedMessageLintRules: () => Array, resolvedModules: () => Awaited> | undefined, - hasWatcher: boolean ): InlangProject["query"]["messageLintReports"] { // @ts-expect-error const index = new ReactiveMap() @@ -41,41 +39,55 @@ export function createMessageLintReportsQuery( const messages = messagesQuery.getAll() as Message[] + const trackedMessages: Map void> = new Map() + createEffect(() => { + const currentMessageIds = new Set(messagesQuery.includedMessageIds()) + + const deletedTrackedMessages = [...trackedMessages].filter( + (tracked) => !currentMessageIds.has(tracked[0]) + ) + if (rulesArray) { - for (const messageId of messagesQuery.includedMessageIds()) { - createEffect(() => { - const message = messagesQuery.get({ where: { id: messageId } }) - if (hasWatcher) { - lintSingleMessage({ - rules: rulesArray, - settings: settingsObject(), - messages: messages, - message: message, - }).then((report) => { - if (report.errors.length === 0 && index.get(messageId) !== report.data) { - index.set(messageId, report.data) + for (const messageId of currentMessageIds) { + if (!trackedMessages.has(messageId)) { + createRoot((dispose) => { + createEffect(() => { + const message = messagesQuery.get({ where: { id: messageId } }) + if (!message) { + return + } + if (!trackedMessages?.has(messageId)) { + // initial effect execution - add dispose function + trackedMessages?.set(messageId, dispose) } + + lintSingleMessage({ + rules: rulesArray, + settings: settingsObject(), + messages: messages, + message: message, + }).then((report) => { + if (report.errors.length === 0 && index.get(messageId) !== report.data) { + index.set(messageId, report.data) + } + }) }) - } else { - debounce( - 500, - (message) => { - lintSingleMessage({ - rules: rulesArray, - settings: settingsObject(), - messages: messages, - message: message, - }).then((report) => { - if (report.errors.length === 0 && index.get(messageId) !== report.data) { - index.set(messageId, report.data) - } - }) - }, - { atBegin: false } - )(message) - } - }) + }) + } + } + + for (const deletedMessage of deletedTrackedMessages) { + const deletedMessageId = deletedMessage[0] + + // call dispose to cleanup the effect + const messageEffectDisposeFunction = trackedMessages.get(deletedMessageId) + if (messageEffectDisposeFunction) { + messageEffectDisposeFunction() + trackedMessages.delete(deletedMessageId) + // remove lint report result + index.delete(deletedMessageId) + } } } }) diff --git a/inlang/source-code/sdk/src/createMessagesQuery.test.ts b/inlang/source-code/sdk/src/createMessagesQuery.test.ts index 0f4a40345f..47bad5f269 100644 --- a/inlang/source-code/sdk/src/createMessagesQuery.test.ts +++ b/inlang/source-code/sdk/src/createMessagesQuery.test.ts @@ -6,6 +6,7 @@ import type { Message, Text } from "@inlang/message" import { createMessage } from "./test-utilities/createMessage.js" const createChangeListener = async (cb: () => void) => createEffect(cb) +const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0)) describe("create", () => { it("should create a message", () => { @@ -19,6 +20,19 @@ describe("create", () => { expect(created).toBe(true) }) + it("query.getByDefaultAlias should return a message with a default alias", () => { + const query = createMessagesQuery(() => []) + expect(query.get({ where: { id: "first-message" } })).toBeUndefined() + + const mockMessage = createMessage("first-message", { en: "Hello World" }) + mockMessage.alias = { default: "first-message-alias" } + const created = query.create({ data: mockMessage }) + + expect(query.get({ where: { id: "first-message" } })).toEqual(mockMessage) + expect(query.getByDefaultAlias("first-message-alias")).toEqual(mockMessage) + expect(created).toBe(true) + }) + it("should return false if message with id already exists", () => { const query = createMessagesQuery(() => [createMessage("first-message", { en: "Hello World" })]) expect(query.get({ where: { id: "first-message" } })).toBeDefined() @@ -266,8 +280,46 @@ describe("reactivity", () => { }) }) - describe.todo("subscribe", () => { - // TODO: add tests for `subscribe` + describe("subscribe", () => { + describe("get", () => { + it("should subscribe to `create`", async () => { + await createRoot(async () => { + const query = createMessagesQuery(() => []) + + // eslint-disable-next-line unicorn/no-null + let message: Message | undefined | null = null + query.get.subscribe({ where: { id: "1" } }, (v) => { + void (message = v) + }) + await nextTick() + expect(message).toBeUndefined() + + query.create({ data: createMessage("1", { en: "before" }) }) + expect(message).toBeDefined() + }) + }) + }) + describe("getByDefaultAlias", () => { + it("should subscribe to `create`", async () => { + await createRoot(async () => { + const query = createMessagesQuery(() => []) + + // eslint-disable-next-line unicorn/no-null + let message: Message | undefined | null = null + query.getByDefaultAlias.subscribe("message-alias", (v) => { + void (message = v) + }) + await nextTick() // required for effect to run on reactive map + expect(message).toBeUndefined() + + const mockMessage = createMessage("1", { en: "before" }) + mockMessage.alias = { default: "message-alias" } + query.create({ data: mockMessage }) + + expect(message).toBeDefined() + }) + }) + }) }) describe("getAll", () => { diff --git a/inlang/source-code/sdk/src/createMessagesQuery.ts b/inlang/source-code/sdk/src/createMessagesQuery.ts index b7479c7e15..05425684e1 100644 --- a/inlang/source-code/sdk/src/createMessagesQuery.ts +++ b/inlang/source-code/sdk/src/createMessagesQuery.ts @@ -13,19 +13,35 @@ export function createMessagesQuery( // @ts-expect-error const index = new ReactiveMap() + // Map default alias to message + // Assumes that aliases are only created and deleted, not updated + // TODO #2346 - handle updates to aliases + // TODO #2346 - refine to hold messageId[], if default alias is not unique + // @ts-expect-error + const defaultAliasIndex = new ReactiveMap() + createEffect(() => { index.clear() for (const message of structuredClone(messages())) { index.set(message.id, message) + if ("default" in message.alias) { + defaultAliasIndex.set(message.alias.default, message) + } } }) const get = (args: Parameters[0]) => index.get(args.where.id) + const getByDefaultAlias = (alias: Parameters[0]) => + defaultAliasIndex.get(alias) + return { create: ({ data }): boolean => { if (index.has(data.id)) return false index.set(data.id, data) + if ("default" in data.alias) { + defaultAliasIndex.set(data.alias.default, data) + } return true }, get: Object.assign(get, { @@ -34,6 +50,12 @@ export function createMessagesQuery( callback: Parameters[1] ) => createSubscribable(() => get(args)).subscribe(callback), }) as any, + getByDefaultAlias: Object.assign(getByDefaultAlias, { + subscribe: ( + alias: Parameters[0], + callback: Parameters[1] + ) => createSubscribable(() => getByDefaultAlias(alias)).subscribe(callback), + }) as any, includedMessageIds: createSubscribable(() => { return [...index.keys()] }), @@ -50,13 +72,20 @@ export function createMessagesQuery( const message = index.get(where.id) if (message === undefined) { index.set(where.id, data) + if ("default" in data.alias) { + defaultAliasIndex.set(data.alias.default, data) + } } else { index.set(where.id, { ...message, ...data }) } return true }, delete: ({ where }): boolean => { - if (!index.has(where.id)) return false + const message = index.get(where.id) + if (message === undefined) return false + if ("default" in message.alias) { + defaultAliasIndex.delete(message.alias.default) + } index.delete(where.id) return true }, diff --git a/inlang/source-code/sdk/src/createNodeishFsWithAbsolutePaths.test.ts b/inlang/source-code/sdk/src/createNodeishFsWithAbsolutePaths.test.ts index e1acdbf49b..4f088d7f51 100644 --- a/inlang/source-code/sdk/src/createNodeishFsWithAbsolutePaths.test.ts +++ b/inlang/source-code/sdk/src/createNodeishFsWithAbsolutePaths.test.ts @@ -1,6 +1,7 @@ import { it, expect, vi } from "vitest" import { createNodeishFsWithAbsolutePaths } from "./createNodeishFsWithAbsolutePaths.js" -import type { NodeishFilesystemSubset } from "./versionedInterfaces.js" +// import type { NodeishFilesystemSubset } from "./versionedInterfaces.js" +import type { NodeishFilesystem } from "@lix-js/fs" it("throws an error if projectPath is not an absolute path", () => { const relativePath = "relative/path" @@ -27,9 +28,16 @@ it("intercepts paths correctly for readFile", async () => { readFile: vi.fn(), readdir: vi.fn(), mkdir: vi.fn(), + rmdir: vi.fn(), writeFile: vi.fn(), watch: vi.fn(), - } satisfies Record + rm: vi.fn(), + stat: vi.fn(), + lstat: vi.fn(), + symlink: vi.fn(), + unlink: vi.fn(), + readlink: vi.fn(), + } satisfies Record const interceptedFs = createNodeishFsWithAbsolutePaths({ projectPath, @@ -38,7 +46,6 @@ it("intercepts paths correctly for readFile", async () => { for (const [path, expectedPath] of filePaths) { for (const fn of Object.keys(mockNodeishFs)) { - // @ts-expect-error await interceptedFs[fn](path) // @ts-expect-error // expect the first argument to be the expectedPath diff --git a/inlang/source-code/sdk/src/createNodeishFsWithAbsolutePaths.ts b/inlang/source-code/sdk/src/createNodeishFsWithAbsolutePaths.ts index f8cde5a8aa..1feaa3158f 100644 --- a/inlang/source-code/sdk/src/createNodeishFsWithAbsolutePaths.ts +++ b/inlang/source-code/sdk/src/createNodeishFsWithAbsolutePaths.ts @@ -1,5 +1,4 @@ -import type { NodeishFilesystemSubset } from "@inlang/plugin" -import { normalizePath } from "@lix-js/fs" +import { normalizePath, type NodeishFilesystem } from "@lix-js/fs" import { isAbsolutePath } from "./isAbsolutePath.js" /** @@ -10,8 +9,8 @@ import { isAbsolutePath } from "./isAbsolutePath.js" */ export const createNodeishFsWithAbsolutePaths = (args: { projectPath: string - nodeishFs: NodeishFilesystemSubset -}): NodeishFilesystemSubset => { + nodeishFs: NodeishFilesystem +}): NodeishFilesystem => { if (!isAbsolutePath(args.projectPath)) { throw new Error(`Expected an absolute path but received "${args.projectPath}".`) } @@ -33,11 +32,21 @@ export const createNodeishFsWithAbsolutePaths = (args: { readFile: (path: string, options: { encoding: "utf-8" | "binary" }) => args.nodeishFs.readFile(makeAbsolute(path), options), readdir: (path: string) => args.nodeishFs.readdir(makeAbsolute(path)), - mkdir: (path: string) => args.nodeishFs.mkdir(makeAbsolute(path)), + mkdir: (path: string, options: { recursive: boolean }) => + args.nodeishFs.mkdir(makeAbsolute(path), options), writeFile: (path: string, data: string) => args.nodeishFs.writeFile(makeAbsolute(path), data), + stat: (path: string) => args.nodeishFs.stat(makeAbsolute(path)), + rm: (path: string) => args.nodeishFs.rm(makeAbsolute(path)), + rmdir: (path: string) => (args.nodeishFs as any).rmdir(makeAbsolute(path)), watch: ( path: string, options: { signal: AbortSignal | undefined; recursive: boolean | undefined } ) => args.nodeishFs.watch(makeAbsolute(path), options), + // This might be surprising when symlinks were intended to be relative + symlink: (target: string, path: string) => + args.nodeishFs.symlink(makeAbsolute(target), makeAbsolute(path)), + unlink: (path: string) => args.nodeishFs.unlink(makeAbsolute(path)), + readlink: (path: string) => args.nodeishFs.readlink(makeAbsolute(path)), + lstat: (path: string) => args.nodeishFs.lstat(makeAbsolute(path)), } } diff --git a/inlang/source-code/sdk/src/createNodeishFsWithWatcher.ts b/inlang/source-code/sdk/src/createNodeishFsWithWatcher.ts index af55d98fdc..36d1099a10 100644 --- a/inlang/source-code/sdk/src/createNodeishFsWithWatcher.ts +++ b/inlang/source-code/sdk/src/createNodeishFsWithWatcher.ts @@ -1,4 +1,4 @@ -import type { NodeishFilesystemSubset } from "@inlang/plugin" +import type { NodeishFilesystem } from "@lix-js/fs" /** * Wraps the nodeish filesystem subset with a function that intercepts paths @@ -7,9 +7,9 @@ import type { NodeishFilesystemSubset } from "@inlang/plugin" * The paths are resolved from the `projectPath` argument. */ export const createNodeishFsWithWatcher = (args: { - nodeishFs: NodeishFilesystemSubset + nodeishFs: NodeishFilesystem updateMessages: () => void -}): NodeishFilesystemSubset => { +}): NodeishFilesystem => { const pathList: string[] = [] const makeWatcher = (path: string) => { @@ -50,9 +50,12 @@ export const createNodeishFsWithWatcher = (args: { // @ts-expect-error readFile: (path: string, options: { encoding: "utf-8" | "binary" }) => readFileAndExtractPath(path, options), + rm: args.nodeishFs.rm, readdir: args.nodeishFs.readdir, mkdir: args.nodeishFs.mkdir, + rmdir: (args.nodeishFs as any).rmdir, writeFile: args.nodeishFs.writeFile, watch: args.nodeishFs.watch, + stat: args.nodeishFs.stat, } } diff --git a/inlang/source-code/sdk/src/errors.ts b/inlang/source-code/sdk/src/errors.ts index 7a1c7ac3c5..b6c4a3cb3f 100644 --- a/inlang/source-code/sdk/src/errors.ts +++ b/inlang/source-code/sdk/src/errors.ts @@ -50,3 +50,23 @@ export class PluginLoadMessagesError extends Error { this.name = "PluginLoadMessagesError" } } + +export class LoadMessageError extends Error { + constructor(options: { path: string; messageId: string; cause: ErrorOptions["cause"] }) { + super( + `An error occured when loading message ${options.messageId} from path ${options.path} caused by ${options.cause}.`, + options + ) + this.name = "LoadMessageError" + } +} + +export class SaveMessageError extends Error { + constructor(options: { path: string; messageId: string; cause: ErrorOptions["cause"] }) { + super( + `An error occured when loading message ${options.messageId} from path ${options.path} caused by ${options.cause}.`, + options + ) + this.name = "SaveMessageError" + } +} diff --git a/inlang/source-code/sdk/src/loadProject.test.ts b/inlang/source-code/sdk/src/loadProject.test.ts index 2d654dd30d..dfa03585cb 100644 --- a/inlang/source-code/sdk/src/loadProject.test.ts +++ b/inlang/source-code/sdk/src/loadProject.test.ts @@ -65,6 +65,7 @@ const mockPlugin: Plugin = { const exampleMessages: Message[] = [ { id: "a", + alias: {}, selectors: [], variants: [ { @@ -81,6 +82,48 @@ const exampleMessages: Message[] = [ }, { id: "b", + alias: {}, + selectors: [], + variants: [ + { + languageTag: "en", + match: [], + pattern: [ + { + type: "Text", + value: "test", + }, + ], + }, + ], + }, +] + +const exampleAliasedMessages: Message[] = [ + { + id: "raw_tapir_pause_grateful", + alias: { + default: "a", + }, + selectors: [], + variants: [ + { + languageTag: "en", + match: [], + pattern: [ + { + type: "Text", + value: "test", + }, + ], + }, + ], + }, + { + id: "dizzy_halibut_dial_vaguely", + alias: { + default: "b", + }, selectors: [], variants: [ { @@ -561,7 +604,7 @@ describe("functionality", () => { description: { en: "Mock plugin description" }, displayName: { en: "Mock Plugin" }, - loadMessages: () => [{ id: "some-message", selectors: [], variants: [] }], + loadMessages: () => [{ id: "some-message", alias: {}, selectors: [], variants: [] }], saveMessages: () => undefined, } const repo = await mockRepo() @@ -613,7 +656,7 @@ describe("functionality", () => { description: { en: "Mock plugin description" }, displayName: { en: "Mock Plugin" }, - loadMessages: () => [{ id: "some-message", selectors: [], variants: [] }], + loadMessages: () => [{ id: "some-message", alias: {}, selectors: [], variants: [] }], saveMessages: () => undefined, } const repo = await mockRepo() @@ -698,6 +741,25 @@ describe("functionality", () => { }) }) + describe("messages with aliases", () => { + it("should return the messages", async () => { + const repo = await mockRepo() + const fs = repo.nodeishFs + await fs.mkdir("/user/project.inlang", { recursive: true }) + await fs.writeFile( + "/user/project.inlang/settings.json", + JSON.stringify({ ...settings, experimental: { aliases: true } }) + ) + const project = await loadProject({ + projectPath: "/user/project.inlang", + repo, + _import, + }) + + expect(Object.values(project.query.messages.getAll())).toEqual(exampleAliasedMessages) + }) + }) + describe("query", () => { it("should call saveMessages() on updates", async () => { const repo = await mockRepo() @@ -744,6 +806,7 @@ describe("functionality", () => { where: { id: "a" }, data: { id: "a", + alias: {}, selectors: [], variants: [ { @@ -774,6 +837,7 @@ describe("functionality", () => { where: { id: "b" }, data: { id: "b", + alias: {}, selectors: [], variants: [ { @@ -800,7 +864,8 @@ describe("functionality", () => { }, }) - await new Promise((resolve) => setTimeout(resolve, 510)) + // lets wait for the next tick + await new Promise((resolve) => setTimeout(resolve, 100)) expect(mockSaveFn.mock.calls.length).toBe(1) @@ -809,25 +874,26 @@ describe("functionality", () => { expect(Object.values(mockSaveFn.mock.calls[0][0].messages)).toStrictEqual([ { id: "a", + alias: {}, selectors: [], variants: [ { - languageTag: "en", + languageTag: "de", match: [], pattern: [ { type: "Text", - value: "a en", + value: "a de", }, ], }, { - languageTag: "de", + languageTag: "en", match: [], pattern: [ { type: "Text", - value: "a de", + value: "a en", }, ], }, @@ -835,25 +901,26 @@ describe("functionality", () => { }, { id: "b", + alias: {}, selectors: [], variants: [ { - languageTag: "en", + languageTag: "de", match: [], pattern: [ { type: "Text", - value: "b en", + value: "b de", }, ], }, { - languageTag: "de", + languageTag: "en", match: [], pattern: [ { type: "Text", - value: "b de", + value: "b en", }, ], }, @@ -926,6 +993,20 @@ describe("functionality", () => { expect(mockSaveFn.mock.calls.length).toBe(1) expect(mockSaveFn.mock.calls[0][0].messages).toHaveLength(4) + + project.query.messages.create({ data: createMessage("fifth", { en: "fifth message" }) }) + + await new Promise((resolve) => setTimeout(resolve, 510)) + + expect(mockSaveFn.mock.calls.length).toBe(2) + expect(mockSaveFn.mock.calls[1][0].messages).toHaveLength(5) + + project.query.messages.delete({ where: { id: "fourth" } }) + + await new Promise((resolve) => setTimeout(resolve, 510)) + + expect(mockSaveFn.mock.calls.length).toBe(3) + expect(mockSaveFn.mock.calls[2][0].messages).toHaveLength(4) }) }) @@ -1040,17 +1121,28 @@ describe("functionality", () => { counter = counter + 1 }) + // subscribe fires once expect(counter).toBe(1) - // change file + // saving the file without changing should not trigger a message query await fs.writeFile("./messages.json", JSON.stringify(messages)) - await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 200)) // file event will lock a file and be handled sequentially - give it time to pickup the change + + // we didn't change the message we write into message.json - shouldn't change the messages + expect(counter).toBe(1) + + // saving the file without changing should trigger a change + messages.data[0]!.variants[0]!.pattern[0]!.value = "changed" + await fs.writeFile("./messages.json", JSON.stringify(messages)) + await new Promise((resolve) => setTimeout(resolve, 200)) // file event will lock a file and be handled sequentially - give it time to pickup the change expect(counter).toBe(2) + messages.data[0]!.variants[0]!.pattern[0]!.value = "changed3" + // change file await fs.writeFile("./messages.json", JSON.stringify(messages)) - await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 200)) // file event will lock a file and be handled sequentially - give it time to pickup the change expect(counter).toBe(3) }) diff --git a/inlang/source-code/sdk/src/loadProject.ts b/inlang/source-code/sdk/src/loadProject.ts index 2feeae2929..8f5bbfd538 100644 --- a/inlang/source-code/sdk/src/loadProject.ts +++ b/inlang/source-code/sdk/src/loadProject.ts @@ -11,29 +11,57 @@ import { ProjectSettingsFileJSONSyntaxError, ProjectSettingsFileNotFoundError, ProjectSettingsInvalidError, - PluginLoadMessagesError, PluginSaveMessagesError, LoadProjectInvalidArgument, + PluginLoadMessagesError, } from "./errors.js" import { createRoot, createSignal, createEffect } from "./reactivity/solid.js" import { createMessagesQuery } from "./createMessagesQuery.js" -import { debounce } from "throttle-debounce" import { createMessageLintReportsQuery } from "./createMessageLintReportsQuery.js" import { ProjectSettings, Message, type NodeishFilesystemSubset } from "./versionedInterfaces.js" import { tryCatch, type Result } from "@inlang/result" import { migrateIfOutdated } from "@inlang/project-settings/migration" import { createNodeishFsWithAbsolutePaths } from "./createNodeishFsWithAbsolutePaths.js" -import { normalizePath } from "@lix-js/fs" +import { normalizePath, type NodeishFilesystem } from "@lix-js/fs" import { isAbsolutePath } from "./isAbsolutePath.js" -import { createNodeishFsWithWatcher } from "./createNodeishFsWithWatcher.js" import { maybeMigrateToDirectory } from "./migrations/migrateToDirectory.js" -import { maybeCreateFirstProjectId } from "./migrations/maybeCreateFirstProjectId.js" + +import { stringifyMessage as stringifyMessage } from "./storage/helper.js" + +import { humanIdHash } from "./storage/human-id/human-readable-id.js" + import type { Repository } from "@lix-js/client" +import { createNodeishFsWithWatcher } from "./createNodeishFsWithWatcher.js" + +import { maybeCreateFirstProjectId } from "./migrations/maybeCreateFirstProjectId.js" + import { capture } from "./telemetry/capture.js" import { identifyProject } from "./telemetry/groupIdentify.js" +import type { NodeishStats } from "../../../../lix/source-code/fs/dist/NodeishFilesystemApi.js" + +import _debug from "debug" +const debug = _debug("loadProject") const settingsCompiler = TypeCompiler.Compile(ProjectSettings) +type MessageState = { + messageDirtyFlags: { + [messageId: string]: boolean + } + messageLoadHash: { + [messageId: string]: string + } + isSaving: boolean + currentSaveMessagesViaPlugin: Promise | undefined + sheduledSaveMessages: + | [awaitable: Promise, resolve: () => void, reject: (e: unknown) => void] + | undefined + isLoading: boolean + sheduledLoadMessagesViaPlugin: + | [awaitable: Promise, resolve: () => void, reject: (e: unknown) => void] + | undefined +} + /** * @param projectPath - Absolute path to the inlang settings file. * @param repo - An instance of a lix repo as returned by `openRepository`. @@ -50,6 +78,16 @@ export async function loadProject(args: { }): Promise { const projectPath = normalizePath(args.projectPath) + const messageStates = { + messageDirtyFlags: {}, + messageLoadHash: {}, + isSaving: false, + currentSaveMessagesViaPlugin: undefined, + sheduledSaveMessages: undefined, + isLoading: false, + sheduledLoadMessagesViaPlugin: undefined, + } as MessageState + // -- validation -------------------------------------------------------- // the only place where throwing is acceptable because the project // won't even be loaded. do not throw anywhere else. otherwise, apps @@ -154,9 +192,23 @@ export async function loadProject(args: { // please don't use this as source of truth, use the query instead // needed for granular linting - const [messages, setMessages] = createSignal() + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- setMessages is not called directly we use the CRUD operations on the messageQuery to set the messages now + const [messages, setMessages] = createSignal([]) + + const [loadMessagesViaPluginError, setLoadMessagesViaPluginError] = createSignal< + Error | undefined + >() + + const [saveMessagesViaPluginError, setSaveMessagesViaPluginError] = createSignal< + Error | undefined + >() + + const messagesQuery = createMessagesQuery(() => messages()) + + const messageLockDirPath = projectPath + "/messagelock" createEffect(() => { + // wait for first effect excution until modules are resolved const _resolvedModules = resolvedModules() if (!_resolvedModules) return @@ -165,28 +217,53 @@ export async function loadProject(args: { return } - const loadAndSetMessages = async (fs: NodeishFilesystemSubset) => { - makeTrulyAsync( - _resolvedModules.resolvedPluginApi.loadMessages({ - settings: settingsValue, - nodeishFs: fs, - }) - ) - .then((messages) => { - setMessages(messages) - markInitAsComplete() - }) - .catch((err) => markInitAsFailed(new PluginLoadMessagesError({ cause: err }))) - } + const _settings = settings() + if (!_settings) return + // get plugin finding the plugin that provides loadMessages function + const loadMessagePlugin = _resolvedModules.plugins.find( + (plugin) => plugin.loadMessages !== undefined + ) + + // TODO #1844 this watcher needs to get pruned when we have a change in the configs which will trigger this again const fsWithWatcher = createNodeishFsWithWatcher({ nodeishFs: nodeishFs, + // this message is called whenever a file changes that was read earlier by this filesystem + // - the plugin loads messages -> reads the file messages.json -> start watching on messages.json -> updateMessages updateMessages: () => { - loadAndSetMessages(nodeishFs) + // preserving console.logs as comments pending # + debug("load messages because of a change in the message.json files") + loadMessagesViaPlugin( + fsWithWatcher, + messageLockDirPath, + messageStates, + messagesQuery, + settings()!, // NOTE we bang here - we don't expect the settings to become null during the livetime of a project + loadMessagePlugin + ) + .catch((e) => setLoadMessagesViaPluginError(new PluginLoadMessagesError({ cause: e }))) + .then(() => { + if (loadMessagesViaPluginError() !== undefined) { + setLoadMessagesViaPluginError(undefined) + } + }) }, }) - loadAndSetMessages(fsWithWatcher) + loadMessagesViaPlugin( + fsWithWatcher, + messageLockDirPath, + messageStates, + messagesQuery, + _settings, + loadMessagePlugin + ) + .then(() => { + markInitAsComplete() + }) + .catch((err) => { + markInitAsFailed(new PluginLoadMessagesError({ cause: err })) + }) }) // -- installed items ---------------------------------------------------- @@ -225,50 +302,114 @@ export async function loadProject(args: { const initializeError: Error | undefined = await initialized.catch((error) => error) const abortController = new AbortController() - const hasWatcher = nodeishFs.watch("/", { signal: abortController.signal }) !== undefined + nodeishFs.watch("/", { signal: abortController.signal }) !== undefined + + // map of message id => dispose function from createRoot for each message + const trackedMessages: Map void> = new Map() + let initialSetup = true + // -- subscribe to all messages and write to files on signal ------------- + createEffect(() => { + // debug("Outer createEffect") + + const _resolvedModules = resolvedModules() + if (!_resolvedModules) return + + const currentMessageIds = new Set(messagesQuery.includedMessageIds()) + const deletedTrackedMessages = [...trackedMessages].filter( + (tracked) => !currentMessageIds.has(tracked[0]) + ) + + const saveMessagesPlugin = _resolvedModules.plugins.find( + (plugin) => plugin.saveMessages !== undefined + ) + const loadMessagesPlugin = _resolvedModules.plugins.find( + (plugin) => plugin.loadMessages !== undefined + ) + + for (const messageId of currentMessageIds) { + if (!trackedMessages!.has(messageId!)) { + // we create a new root to be able to cleanup an effect for a message that got deleted + createRoot((dispose) => { + createEffect(() => { + // debug("Inner createEffect", messageId) + + const message = messagesQuery.get({ where: { id: messageId } })! + if (!message) { + return + } + if (!trackedMessages?.has(messageId)) { + // initial effect execution - add dispose function + trackedMessages?.set(messageId, dispose) + } + + // don't trigger saves or set dirty flags during initial setup + if (!initialSetup) { + messageStates.messageDirtyFlags[message.id] = true + saveMessagesViaPlugin( + fs, + messageLockDirPath, + messageStates, + messagesQuery, + settings()!, + saveMessagesPlugin, + loadMessagesPlugin + ) + .catch((e) => + setSaveMessagesViaPluginError(new PluginSaveMessagesError({ cause: e })) + ) + .then(() => { + if (saveMessagesViaPluginError() !== undefined) { + setSaveMessagesViaPluginError(undefined) + } + }) + } + }) + }) + } + } + + for (const deletedMessage of deletedTrackedMessages) { + const deletedMessageId = deletedMessage[0] + + // call dispose to cleanup the effect + const messageEffectDisposeFunction = trackedMessages.get(deletedMessageId) + if (messageEffectDisposeFunction) { + messageEffectDisposeFunction() + trackedMessages.delete(deletedMessageId) + } + // mark the deleted message as dirty to force a save + messageStates.messageDirtyFlags[deletedMessageId] = true + } + + if (deletedTrackedMessages.length > 0) { + // we keep track of the latest save within the loadProject call to await it at the end - this is not used in subsequetial upserts + saveMessagesViaPlugin( + nodeishFs, + messageLockDirPath, + messageStates, + messagesQuery, + settings()!, + saveMessagesPlugin, + loadMessagesPlugin + ) + .catch((e) => setSaveMessagesViaPluginError(new PluginSaveMessagesError({ cause: e }))) + .then(() => { + if (saveMessagesViaPluginError() !== undefined) { + setSaveMessagesViaPluginError(undefined) + } + }) + } + + initialSetup = false + }) - const messagesQuery = createMessagesQuery(() => messages() || []) const lintReportsQuery = createMessageLintReportsQuery( messagesQuery, settings as () => ProjectSettings, installedMessageLintRules, - resolvedModules, - hasWatcher - ) - - const debouncedSave = skipFirst( - debounce( - 500, - async (newMessages) => { - try { - if (JSON.stringify(newMessages) !== JSON.stringify(messages())) { - await resolvedModules()?.resolvedPluginApi.saveMessages({ - settings: settingsValue, - messages: newMessages, - }) - } - } catch (err) { - throw new PluginSaveMessagesError({ - cause: err, - }) - } - const abortController = new AbortController() - if ( - newMessages.length !== 0 && - JSON.stringify(newMessages) !== JSON.stringify(messages()) && - nodeishFs.watch("/", { signal: abortController.signal }) !== undefined - ) { - setMessages(newMessages) - } - }, - { atBegin: false } - ) + resolvedModules ) - createEffect(() => { - debouncedSave(messagesQuery.getAll()) - }) - /** * Utility to escape reactive tracking and avoid multiple calls to * the capture event. @@ -309,6 +450,10 @@ export async function loadProject(args: { errors: createSubscribable(() => [ ...(initializeError ? [initializeError] : []), ...(resolvedModules() ? resolvedModules()!.errors : []), + ...(loadMessagesViaPluginError() ? [loadMessagesViaPluginError()!] : []), + ...(saveMessagesViaPluginError() ? [saveMessagesViaPluginError()!] : []), + // have a query error exposed + //...(lintErrors() ?? []), ]), settings: createSubscribable(() => settings() as ProjectSettings), setSettings, @@ -321,8 +466,6 @@ export async function loadProject(args: { }) } -//const x = {} as InlangProject - // ------------------------------------------------------------------------------------------------ const loadSettings = async (args: { @@ -448,3 +591,455 @@ export function createSubscribable(signal: () => T): Subscribable { }, }) } + +// --- serialization of loading / saving messages. +// 1. A plugin saveMessage call can not be called simultaniously to avoid side effects - its an async function not controlled by us +// 2. loading and saving must not run in "parallel". +// - json plugin exports into separate file per language. +// - saving a message in two different languages would lead to a write in de.json first +// - This will leads to a load of the messages and since en.json has not been saved yet the english variant in the message would get overritten with the old state again + +/** + * Messsage that loads messages from a plugin - this method synchronizes with the saveMessage funciton. + * If a save is in progress loading will wait until saving is done. If another load kicks in during this load it will queue the + * load and execute it at the end of this load. subsequential loads will not be queued but the same promise will be reused + * + * - NOTE: this means that the parameters used to load like settingsValue and loadPlugin might not take into account. this has to be refactored + * with the loadProject restructuring + * @param fs + * @param messagesQuery + * @param settingsValue + * @param loadPlugin + * @returns void - updates the files and messages in of the project in place + */ +async function loadMessagesViaPlugin( + fs: NodeishFilesystem, + lockDirPath: string, + messageState: MessageState, + messagesQuery: InlangProject["query"]["messages"], + settingsValue: ProjectSettings, + loadPlugin: any +) { + const experimentalAliases = !!settingsValue.experimental?.aliases + + // loading is an asynchronous process - check if another load is in progress - queue this call if so + if (messageState.isLoading) { + if (!messageState.sheduledLoadMessagesViaPlugin) { + messageState.sheduledLoadMessagesViaPlugin = createAwaitable() + } + // another load will take place right after the current one - its goingt to be idempotent form the current requested one - don't reschedule + return messageState.sheduledLoadMessagesViaPlugin[0] + } + + // set loading flag + messageState.isLoading = true + let lockTime: number | undefined = undefined + + try { + lockTime = await acquireFileLock(fs as NodeishFilesystem, lockDirPath, "loadMessage") + const loadedMessages = await makeTrulyAsync( + loadPlugin.loadMessages({ + settings: settingsValue, + nodeishFs: fs, + }) + ) + + for (const loadedMessage of loadedMessages) { + const loadedMessageClone = structuredClone(loadedMessage) + + const currentMessages = messagesQuery + .getAll() + // TODO #1585 here we match using the id to support legacy load message plugins - after we introduced import / export methods we will use importedMessage.alias + .filter( + (message: any) => + (experimentalAliases ? message.alias["default"] : message.id) === loadedMessage.id + ) + + if (currentMessages.length > 1) { + // NOTE: if we happen to find two messages witht the sam alias we throw for now + // - this could be the case if one edits the aliase manualy + throw new Error("more than one message with the same id or alias found ") + } else if (currentMessages.length === 1) { + // update message in place - leave message id and alias untouched + loadedMessageClone.alias = {} as any + + // TODO #1585 we have to map the id of the importedMessage to the alias and fill the id property with the id of the existing message - change when import mesage provides importedMessage.alias + if (experimentalAliases) { + loadedMessageClone.alias["default"] = loadedMessageClone.id + loadedMessageClone.id = currentMessages[0]!.id + } + + // NOTE stringifyMessage encodes messages independent from key order! + const importedEnecoded = stringifyMessage(loadedMessageClone) + + // NOTE could use hash instead of the whole object JSON to save memory... + if (messageState.messageLoadHash[loadedMessageClone.id] === importedEnecoded) { + debug("skipping upsert!") + continue + } + + // This logic is preventing cycles - could also be handled if update api had a parameter for who triggered update + // e.g. when FS was updated, we don't need to write back to FS + // update is synchronous, so update effect will be triggered immediately + // NOTE: this might trigger a save before we have the chance to delete - but since save is async and waits for the lock acquired by this method - its save to set the flags afterwards + messagesQuery.update({ where: { id: loadedMessageClone.id }, data: loadedMessageClone }) + // we load a fresh version - lets delete dirty flag that got created by the update + delete messageState.messageDirtyFlags[loadedMessageClone.id] + // NOTE could use hash instead of the whole object JSON to save memory... + messageState.messageLoadHash[loadedMessageClone.id] = importedEnecoded + } else { + // message with the given alias does not exist so far + loadedMessageClone.alias = {} as any + // TODO #1585 we have to map the id of the importedMessage to the alias - change when import mesage provides importedMessage.alias + if (experimentalAliases) { + loadedMessageClone.alias["default"] = loadedMessageClone.id + + let currentOffset = 0 + let messsageId: string | undefined + do { + messsageId = humanIdHash(loadedMessageClone.id, currentOffset) + if (messagesQuery.get({ where: { id: messsageId } })) { + currentOffset += 1 + messsageId = undefined + } + } while (messsageId === undefined) + + // create a humanId based on a hash of the alias + loadedMessageClone.id = messsageId + } + + const importedEnecoded = stringifyMessage(loadedMessageClone) + + // add the message - this will trigger an async file creation in the backgound! + messagesQuery.create({ data: loadedMessageClone }) + // we load a fresh version - lets delete dirty flag that got created by the create method + delete messageState.messageDirtyFlags[loadedMessageClone.id] + messageState.messageLoadHash[loadedMessageClone.id] = importedEnecoded + } + } + await releaseLock(fs as NodeishFilesystem, lockDirPath, "loadMessage", lockTime) + lockTime = undefined + + debug("loadMessagesViaPlugin: " + loadedMessages.length + " Messages processed ") + + messageState.isLoading = false + } finally { + if (lockTime !== undefined) { + await releaseLock(fs as NodeishFilesystem, lockDirPath, "loadMessage", lockTime) + } + messageState.isLoading = false + } + + const executingScheduledMessages = messageState.sheduledLoadMessagesViaPlugin + if (executingScheduledMessages) { + // a load has been requested during the load - executed it + + // reset sheduling to except scheduling again + messageState.sheduledLoadMessagesViaPlugin = undefined + + // recall load unawaited to allow stack to pop + loadMessagesViaPlugin(fs, lockDirPath, messageState, messagesQuery, settingsValue, loadPlugin) + .then(() => { + // resolve the scheduled load message promise + executingScheduledMessages[1]() + }) + .catch((e: Error) => { + // reject the scheduled load message promise + executingScheduledMessages[2](e) + }) + } +} + +async function saveMessagesViaPlugin( + fs: NodeishFilesystem, + lockDirPath: string, + messageState: MessageState, + messagesQuery: InlangProject["query"]["messages"], + settingsValue: ProjectSettings, + savePlugin: any, + loadPlugin: any +): Promise { + // queue next save if we have a save ongoing + if (messageState.isSaving) { + if (!messageState.sheduledSaveMessages) { + messageState.sheduledSaveMessages = createAwaitable() + } + + return messageState.sheduledSaveMessages[0] + } + + // set isSavingFlag + messageState.isSaving = true + + messageState.currentSaveMessagesViaPlugin = (async function () { + const saveMessageHashes = {} as { [messageId: string]: string } + + // check if we have any dirty message - witho + if (Object.keys(messageState.messageDirtyFlags).length == 0) { + // nothing to save :-) + debug("save was skipped - no messages marked as dirty... build!") + messageState.isSaving = false + return + } + + let messageDirtyFlagsBeforeSave: typeof messageState.messageDirtyFlags | undefined + let lockTime: number | undefined + try { + lockTime = await acquireFileLock(fs as NodeishFilesystem, lockDirPath, "saveMessage") + + // since it may takes some time to acquire the lock we check if the save is required still (loadMessage could have happend in between) + if (Object.keys(messageState.messageDirtyFlags).length == 0) { + debug("save was skipped - no messages marked as dirty... releasing lock again") + messageState.isSaving = false + // release lock in finally block + return + } + + const currentMessages = messagesQuery.getAll() + + const messagesToExport: Message[] = [] + for (const message of currentMessages) { + if (messageState.messageDirtyFlags[message.id]) { + const importedEnecoded = stringifyMessage(message) + // NOTE: could use hash instead of the whole object JSON to save memory... + saveMessageHashes[message.id] = importedEnecoded + } + + const fixedExportMessage = { ...message } + // TODO #1585 here we match using the id to support legacy load message plugins - after we introduced import / export methods we will use importedMessage.alias + if (settingsValue.experimental?.aliases) { + fixedExportMessage.id = fixedExportMessage.alias["default"] ?? fixedExportMessage.id + } + + messagesToExport.push(fixedExportMessage) + } + + // wa are about to save the messages to the plugin - reset all flags now + messageDirtyFlagsBeforeSave = { ...messageState.messageDirtyFlags } + messageState.messageDirtyFlags = {} + + // NOTE: this assumes that the plugin will handle message ordering + await savePlugin.saveMessages({ + settings: settingsValue, + messages: messagesToExport, + nodeishFs: fs, + }) + + for (const [messageId, messageHash] of Object.entries(saveMessageHashes)) { + messageState.messageLoadHash[messageId] = messageHash + } + + if (lockTime !== undefined) { + await releaseLock(fs as NodeishFilesystem, lockDirPath, "saveMessage", lockTime) + lockTime = undefined + } + + // if there is a queued load, allow it to take the lock before we run additional saves. + if (messageState.sheduledLoadMessagesViaPlugin) { + debug("saveMessagesViaPlugin calling queued loadMessagesViaPlugin to share lock") + await loadMessagesViaPlugin( + fs, + lockDirPath, + messageState, + messagesQuery, + settingsValue, + loadPlugin + ) + } + + messageState.isSaving = false + } catch (err) { + // something went wrong - add dirty flags again + if (messageDirtyFlagsBeforeSave !== undefined) { + for (const dirtyMessageId of Object.keys(messageDirtyFlagsBeforeSave)) { + messageState.messageDirtyFlags[dirtyMessageId] = true + } + } + + if (lockTime !== undefined) { + await releaseLock(fs as NodeishFilesystem, lockDirPath, "saveMessage", lockTime) + lockTime = undefined + } + messageState.isSaving = false + + // ok an error + throw new PluginSaveMessagesError({ + cause: err, + }) + } finally { + if (lockTime !== undefined) { + await releaseLock(fs as NodeishFilesystem, lockDirPath, "saveMessage", lockTime) + lockTime = undefined + } + messageState.isSaving = false + } + })() + + await messageState.currentSaveMessagesViaPlugin + + if (messageState.sheduledSaveMessages) { + const executingSheduledSaveMessages = messageState.sheduledSaveMessages + messageState.sheduledSaveMessages = undefined + + saveMessagesViaPlugin( + fs, + lockDirPath, + messageState, + messagesQuery, + settingsValue, + savePlugin, + loadPlugin + ) + .then(() => { + executingSheduledSaveMessages[1]() + }) + .catch((e: Error) => { + executingSheduledSaveMessages[2](e) + }) + } +} + +const maxRetries = 5 +const nProbes = 50 +const probeInterval = 100 +async function acquireFileLock( + fs: NodeishFilesystem, + lockDirPath: string, + lockOrigin: string, + tryCount: number = 0 +): Promise { + if (tryCount > maxRetries) { + throw new Error(lockOrigin + " exceeded maximum Retries (5) to acquire lockfile " + tryCount) + } + + try { + debug(lockOrigin + " tries to acquire a lockfile Retry Nr.: " + tryCount) + await fs.mkdir(lockDirPath) + const stats = await fs.stat(lockDirPath) + debug(lockOrigin + " acquired a lockfile Retry Nr.: " + tryCount) + return stats.mtimeMs + } catch (error: any) { + if (error.code !== "EEXIST") { + // we only expect the error that the file exists already (locked by other process) + throw error + } + } + + let currentLockTime: number + + try { + const stats = await fs.stat(lockDirPath) + currentLockTime = stats.mtimeMs + } catch (fstatError: any) { + if (fstatError.code === "ENOENT") { + // lock file seems to be gone :) - lets try again + debug(lockOrigin + " tryCount++ lock file seems to be gone :) - lets try again " + tryCount) + return acquireFileLock(fs, lockDirPath, lockOrigin, tryCount + 1) + } + throw fstatError + } + debug( + lockOrigin + + " tries to acquire a lockfile - lock currently in use... starting probe phase " + + tryCount + ) + + return new Promise((resolve, reject) => { + let probeCounts = 0 + const scheduleProbationTimeout = () => { + setTimeout(async () => { + probeCounts += 1 + let lockFileStats: undefined | NodeishStats = undefined + try { + debug( + lockOrigin + " tries to acquire a lockfile - check if the lock is free now " + tryCount + ) + + // alright lets give it another try + lockFileStats = await fs.stat(lockDirPath) + } catch (fstatError: any) { + if (fstatError.code === "ENOENT") { + debug( + lockOrigin + + " tryCount++ in Promise - tries to acquire a lockfile - lock file seems to be free now - try to acquire " + + tryCount + ) + const lock = acquireFileLock(fs, lockDirPath, lockOrigin, tryCount + 1) + return resolve(lock) + } + return reject(fstatError) + } + + // still the same locker! - + if (lockFileStats.mtimeMs === currentLockTime) { + if (probeCounts >= nProbes) { + // ok maximum lock time ran up (we waitetd nProbes * probeInterval) - we consider the lock to be stale + debug( + lockOrigin + + " tries to acquire a lockfile - lock not free - but stale lets drop it" + + tryCount + ) + try { + await fs.rmdir(lockDirPath) + } catch (rmLockError: any) { + if (rmLockError.code === "ENOENT") { + // lock already gone? + // Option 1: The "stale process" decided to get rid of it + // Option 2: Another process acquiring the lock and detected a stale one as well + } + return reject(rmLockError) + } + try { + debug( + lockOrigin + + " tryCount++ same locker - try to acquire again after removing stale lock " + + tryCount + ) + const lock = await acquireFileLock(fs, lockDirPath, lockOrigin, tryCount + 1) + return resolve(lock) + } catch (lockAquireException) { + return reject(lockAquireException) + } + } else { + // lets schedule a new probation + return scheduleProbationTimeout() + } + } else { + try { + debug(lockOrigin + " tryCount++ different locker - try to acquire again " + tryCount) + const lock = await acquireFileLock(fs, lockDirPath, lockOrigin, tryCount + 1) + return resolve(lock) + } catch (error) { + return reject(error) + } + } + }, probeInterval) + } + scheduleProbationTimeout() + }) +} + +async function releaseLock( + fs: NodeishFilesystem, + lockDirPath: string, + lockOrigin: string, + lockTime: number +) { + debug(lockOrigin + " releasing the lock ") + try { + const stats = await fs.stat(lockDirPath) + if (stats.mtimeMs === lockTime) { + // this can be corrupt as welll since the last getStat and the current a modification could have occured :-/ + await fs.rmdir(lockDirPath) + } + } catch (statError: any) { + debug(lockOrigin + " couldn't release the lock") + if (statError.code === "ENOENT") { + // ok seeks like the log was released by someone else + debug(lockOrigin + " WARNING - the lock was released by a different process") + return + } + debug(statError) + throw statError + } +} diff --git a/inlang/source-code/sdk/src/messages/variant.test.ts b/inlang/source-code/sdk/src/messages/variant.test.ts index 50f9ff04a6..22b63ff76c 100644 --- a/inlang/source-code/sdk/src/messages/variant.test.ts +++ b/inlang/source-code/sdk/src/messages/variant.test.ts @@ -43,6 +43,7 @@ describe("getVariant", () => { test("it should not throw error if selector is empty and match", () => { const mockMessage: Message = { id: "mockMessage", + alias: {}, selectors: [], variants: [ { @@ -69,6 +70,7 @@ describe("getVariant", () => { test("it should not throw error if selector is empty, return undefined", () => { const mockMessage: Message = { id: "mockMessage", + alias: {}, selectors: [], variants: [ { @@ -397,6 +399,7 @@ describe("updateVariant", () => { const getMockMessage = (): Message => { return { id: "first-message", + alias: {}, selectors: [ { type: "VariableReference", name: "gender" }, { type: "VariableReference", name: "guestOther" }, diff --git a/inlang/source-code/sdk/src/resolve-modules/plugins/resolvePlugins.test.ts b/inlang/source-code/sdk/src/resolve-modules/plugins/resolvePlugins.test.ts index 05decf7cd6..88b70b185e 100644 --- a/inlang/source-code/sdk/src/resolve-modules/plugins/resolvePlugins.test.ts +++ b/inlang/source-code/sdk/src/resolve-modules/plugins/resolvePlugins.test.ts @@ -70,7 +70,9 @@ describe("loadMessages", () => { id: "plugin.namespace.placeholder", description: { en: "My plugin description" }, displayName: { en: "My plugin" }, - loadMessages: async () => [{ id: "test", expressions: [], selectors: [], variants: [] }], + loadMessages: async () => [ + { id: "test", alias: {}, expressions: [], selectors: [], variants: [] }, + ], } const resolved = await resolvePlugins({ @@ -84,7 +86,7 @@ describe("loadMessages", () => { settings: {} as any, nodeishFs: {} as any, }) - ).toEqual([{ id: "test", expressions: [], selectors: [], variants: [] }]) + ).toEqual([{ id: "test", alias: {}, expressions: [], selectors: [], variants: [] }]) }) it("should collect an error if function is defined twice in multiple plugins", async () => { diff --git a/inlang/source-code/sdk/src/storage/helper.ts b/inlang/source-code/sdk/src/storage/helper.ts new file mode 100644 index 0000000000..a10f04ff0f --- /dev/null +++ b/inlang/source-code/sdk/src/storage/helper.ts @@ -0,0 +1,48 @@ +import { Message, Variant } from "../versionedInterfaces.js" + +const fileExtension = ".json" + +export function getMessageIdFromPath(path: string) { + if (!path.endsWith(fileExtension)) { + return + } + + const cleanedPath = path.replace(/\/$/, "") // This regex matches a trailing slash and replaces it with an empty string + const messageFileName = cleanedPath.split("/").join("_") // we split by the first leading namespace or _ separator - make sure slashes don't exit in the id + // const messageFileName = pathParts.at(-1)! + + const lastDotIndex = messageFileName.lastIndexOf(".") + + // Extract until the last dot (excluding the dot) + return messageFileName.slice(0, Math.max(0, lastDotIndex)) +} + +export function getPathFromMessageId(id: string) { + const path = id.replace("_", "/") + fileExtension + return path +} + +export function stringifyMessage(message: Message) { + // create a new object do specify key output order + const messageWithSortedKeys: any = {} + for (const key of Object.keys(message).sort()) { + messageWithSortedKeys[key] = (message as any)[key] + } + + // lets order variants as well + messageWithSortedKeys["variants"] = messageWithSortedKeys["variants"].sort( + (variantA: Variant, variantB: Variant) => { + // First, compare by language + const languageComparison = variantA.languageTag.localeCompare(variantB.languageTag) + + // If languages are the same, compare by match + if (languageComparison === 0) { + return variantA.match.join("-").localeCompare(variantB.match.join("-")) + } + + return languageComparison + } + ) + + return JSON.stringify(messageWithSortedKeys, undefined, 4) +} diff --git a/inlang/source-code/sdk/src/storage/human-id/human-readable-id.ts b/inlang/source-code/sdk/src/storage/human-id/human-readable-id.ts new file mode 100644 index 0000000000..1f29d2eb9e --- /dev/null +++ b/inlang/source-code/sdk/src/storage/human-id/human-readable-id.ts @@ -0,0 +1,27 @@ +// we use murmur for best distribution https://medium.com/miro-engineering/choosing-a-hash-function-to-solve-a-data-sharding-problem-c656259e2b54 +import murmurhash3 from "murmurhash3js" +import { adjectives, animals, adverbs, verbs } from "./words.js" + +export function randomHumanId() { + return `${adjectives[Math.floor(Math.random() * 256)]}_${ + adjectives[Math.floor(Math.random() * 256)] + }_${animals[Math.floor(Math.random() * 256)]}_${verbs[Math.floor(Math.random() * 256)]}` +} + +export function humanIdHash(value: string, offset: number = 0) { + // Seed value can be any arbitrary value + const seed = 42 + + // Use MurmurHash3 to produce a 32-bit hash + const hash32 = murmurhash3.x86.hash32(value, seed) + // Add 1 and take modulo 2^32 to fit within 32 bits + const hash32WithOffset: number = (hash32 + offset) >>> 0 + + // Extract four 8-bit parts + const part1 = (hash32WithOffset >>> 24) & 0xff + const part2 = (hash32WithOffset >>> 16) & 0xff + const part3 = (hash32WithOffset >>> 8) & 0xff + const part4 = hash32WithOffset & 0xff + + return `${adjectives[part1]}_${animals[part2]}_${verbs[part3]}_${adverbs[part4]}` +} diff --git a/inlang/source-code/sdk/src/storage/human-id/words.test.ts b/inlang/source-code/sdk/src/storage/human-id/words.test.ts new file mode 100644 index 0000000000..8065185b61 --- /dev/null +++ b/inlang/source-code/sdk/src/storage/human-id/words.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest" +import { adjectives, animals, adverbs, verbs } from "./words.js" + +const wordlists = [adjectives, animals, adverbs, verbs] +const allwords = [...adjectives, ...animals, ...adverbs, ...verbs] + +describe("wordlists", () => { + it("should have 256 words", () => { + for (const wordlist of wordlists) { + expect(wordlist.length).toBe(256) + } + }) + it("words should be unique across lists", () => { + const unique = new Set(allwords) + expect(unique.size).toBe(256 * 4) + }) + it("words should have < 10 characters", () => { + for (const word of allwords) { + expect(word.length).toBeLessThan(10) + } + }) + it("words should match /^[a-z]+$/", () => { + for (const word of allwords) { + expect(word.match(/^[a-z]+$/) !== null).toBe(true) + } + }) +}) diff --git a/inlang/source-code/sdk/src/storage/human-id/words.ts b/inlang/source-code/sdk/src/storage/human-id/words.ts new file mode 100644 index 0000000000..f644e6172a --- /dev/null +++ b/inlang/source-code/sdk/src/storage/human-id/words.ts @@ -0,0 +1,1035 @@ +export const animals = [ + "albatross", + "alligator", + "alpaca", + "anaconda", + "angelfish", + "ant", + "anteater", + "antelope", + "ape", + "baboon", + "badger", + "barbel", + "bat", + "bear", + "beaver", + "bee", + "beetle", + "bird", + "bison", + "blackbird", + "boar", + "bobcat", + "bulldog", + "bullock", + "bumblebee", + "butterfly", + "buzzard", + "camel", + "canary", + "capybara", + "carp", + "cat", + "cheetah", + "chicken", + "chipmunk", + "clownfish", + "cobra", + "cockroach", + "cod", + "cougar", + "cow", + "cowfish", + "coyote", + "crab", + "crocodile", + "crossbill", + "crow", + "cuckoo", + "dachshund", + "deer", + "dingo", + "dog", + "dolphin", + "donkey", + "dove", + "dragonfly", + "duck", + "eagle", + "earthworm", + "eel", + "elephant", + "elk", + "emu", + "falcon", + "felix", + "finch", + "fireant", + "firefox", + "fish", + "flamingo", + "flea", + "florian", + "fly", + "fox", + "frog", + "gadfly", + "gazelle", + "gecko", + "gibbon", + "giraffe", + "goat", + "goldfish", + "goose", + "gopher", + "gorilla", + "grebe", + "grizzly", + "gull", + "guppy", + "haddock", + "halibut", + "hamster", + "hare", + "hawk", + "hedgehog", + "herring", + "hornet", + "horse", + "hound", + "husky", + "hyena", + "ibex", + "iguana", + "impala", + "insect", + "jackal", + "jackdaw", + "jaguar", + "jan", + "jannes", + "javelina", + "jay", + "jellyfish", + "jurgen", + "kangaroo", + "kestrel", + "kitten", + "koala", + "kudu", + "ladybug", + "lamb", + "lark", + "larva", + "lemming", + "lemur", + "leopard", + "liger", + "lion", + "lionfish", + "lizard", + "llama", + "lobster", + "loris", + "lynx", + "macaw", + "maggot", + "mallard", + "mammoth", + "manatee", + "mantis", + "mare", + "marlin", + "marmot", + "marten", + "martin", + "mayfly", + "meerkat", + "midge", + "millipede", + "mink", + "mole", + "mongoose", + "monkey", + "moose", + "moth", + "mouse", + "mule", + "myna", + "newt", + "niklas", + "nils", + "nuthatch", + "ocelot", + "octopus", + "okapi", + "opossum", + "orangutan", + "oryx", + "osprey", + "ostrich", + "otter", + "owl", + "ox", + "panda", + "panther", + "parakeet", + "parrot", + "peacock", + "pelican", + "penguin", + "pig", + "pigeon", + "piranha", + "platypus", + "polecat", + "pony", + "poodle", + "porpoise", + "puffin", + "pug", + "puma", + "quail", + "rabbit", + "racoon", + "rat", + "raven", + "ray", + "reindeer", + "robin", + "rook", + "rooster", + "salmon", + "samuel", + "sawfish", + "scallop", + "seahorse", + "seal", + "shad", + "shark", + "sheep", + "shell", + "shrike", + "shrimp", + "skate", + "skunk", + "sloth", + "slug", + "snail", + "snake", + "sparrow", + "spider", + "squid", + "squirrel", + "starfish", + "stingray", + "stork", + "swallow", + "swan", + "tadpole", + "tapir", + "termite", + "tern", + "thrush", + "tiger", + "toad", + "tortoise", + "toucan", + "trout", + "tuna", + "turkey", + "turtle", + "vole", + "vulture", + "wallaby", + "walrus", + "warbler", + "warthog", + "wasp", + "weasel", + "whale", + "wolf", + "wombat", + "worm", + "wren", + "yak", + "zebra", +] + +export const adjectives = [ + "acidic", + "active", + "actual", + "agent", + "ago", + "alert", + "alive", + "aloof", + "antsy", + "any", + "aqua", + "arable", + "awake", + "aware", + "away", + "awful", + "bad", + "bald", + "basic", + "best", + "big", + "bland", + "blue", + "bold", + "born", + "brave", + "brief", + "bright", + "broad", + "busy", + "calm", + "candid", + "careful", + "caring", + "chunky", + "civil", + "clean", + "clear", + "close", + "cool", + "cozy", + "crazy", + "crisp", + "cuddly", + "curly", + "cute", + "dark", + "day", + "deft", + "direct", + "dirty", + "dizzy", + "drab", + "dry", + "due", + "dull", + "each", + "early", + "east", + "elegant", + "empty", + "equal", + "even", + "every", + "extra", + "factual", + "fair", + "fancy", + "few", + "fine", + "fit", + "flaky", + "flat", + "fluffy", + "formal", + "frail", + "free", + "fresh", + "front", + "full", + "fun", + "funny", + "fuzzy", + "game", + "gaudy", + "giant", + "glad", + "good", + "grand", + "grassy", + "gray", + "great", + "green", + "gross", + "happy", + "heavy", + "helpful", + "heroic", + "home", + "honest", + "hour", + "house", + "icy", + "ideal", + "inclusive", + "inner", + "jolly", + "jumpy", + "just", + "keen", + "key", + "kind", + "knotty", + "known", + "large", + "last", + "late", + "lazy", + "least", + "left", + "legal", + "less", + "level", + "light", + "lime", + "livid", + "lofty", + "long", + "loose", + "lost", + "loud", + "loved", + "low", + "lower", + "lucky", + "mad", + "main", + "major", + "male", + "many", + "maroon", + "mealy", + "mean", + "mellow", + "merry", + "mild", + "minor", + "misty", + "moving", + "muddy", + "mushy", + "neat", + "new", + "next", + "nice", + "nimble", + "noble", + "noisy", + "north", + "novel", + "odd", + "ok", + "only", + "orange", + "ornate", + "patchy", + "patient", + "petty", + "pink", + "plain", + "plane", + "polite", + "pretty", + "proof", + "proud", + "quaint", + "quick", + "quiet", + "raw", + "real", + "red", + "round", + "royal", + "sad", + "safe", + "salty", + "same", + "sea", + "seemly", + "sharp", + "short", + "shy", + "silly", + "simple", + "sleek", + "slimy", + "slow", + "small", + "smart", + "smug", + "soft", + "solid", + "sound", + "sour", + "spare", + "spicy", + "spry", + "stale", + "steep", + "still", + "stock", + "stout", + "strong", + "suave", + "such", + "sunny", + "super", + "sweet", + "swift", + "tame", + "tangy", + "tasty", + "teal", + "teary", + "tense", + "that", + "these", + "this", + "tidy", + "tiny", + "tired", + "top", + "topical", + "tough", + "trick", + "trite", + "true", + "upper", + "vexed", + "vivid", + "wacky", + "warm", + "watery", + "weak", + "weary", + "weird", + "white", + "whole", + "wide", + "wild", + "wise", + "witty", + "yummy", + "zany", + "zesty", + "zippy", +] + +export const adverbs = [ + "ablaze", + "about", + "above", + "abroad", + "across", + "adrift", + "afloat", + "after", + "again", + "ahead", + "alike", + "all", + "almost", + "alone", + "along", + "aloud", + "always", + "amazing", + "anxious", + "anywhere", + "apart", + "around", + "arrogant", + "aside", + "asleep", + "awkward", + "back", + "bashful", + "beautiful", + "before", + "behind", + "below", + "beside", + "besides", + "beyond", + "bitter", + "bleak", + "blissful", + "boldly", + "bravely", + "briefly", + "brightly", + "brisk", + "busily", + "calmly", + "carefully", + "careless", + "cautious", + "certain", + "cheerful", + "clearly", + "clever", + "closely", + "closer", + "colorful", + "common", + "correct", + "cross", + "cruel", + "curious", + "daily", + "dainty", + "daring", + "dear", + "desperate", + "diligent", + "doubtful", + "doubtless", + "down", + "downwards", + "dreamily", + "eager", + "easily", + "either", + "elegantly", + "else", + "elsewhere", + "enormous", + "enough", + "ever", + "famous", + "far", + "fast", + "fervent", + "fierce", + "fondly", + "foolish", + "forever", + "forth", + "fortunate", + "forward", + "frank", + "freely", + "frequent", + "fully", + "general", + "generous", + "gladly", + "graceful", + "grateful", + "gratis", + "half", + "happily", + "hard", + "harsh", + "hearty", + "helpless", + "here", + "highly", + "hitherto", + "how", + "however", + "hurried", + "immediate", + "in", + "indeed", + "inland", + "innocent", + "inside", + "instant", + "intense", + "inward", + "jealous", + "jovial", + "joyful", + "jubilant", + "keenly", + "kindly", + "knowing", + "lately", + "lazily", + "lightly", + "likely", + "little", + "live", + "loftily", + "longing", + "loosely", + "loudly", + "loving", + "loyal", + "luckily", + "madly", + "maybe", + "meanwhile", + "mocking", + "monthly", + "moreover", + "much", + "near", + "neatly", + "neither", + "nervous", + "never", + "noisily", + "normal", + "not", + "now", + "nowadays", + "nowhere", + "oddly", + "off", + "official", + "often", + "on", + "once", + "open", + "openly", + "opposite", + "otherwise", + "out", + "outside", + "over", + "overall", + "overhead", + "overnight", + "overseas", + "parallel", + "partial", + "past", + "patiently", + "perfect", + "perhaps", + "physical", + "playful", + "politely", + "potential", + "powerful", + "presto", + "profound", + "prompt", + "proper", + "proudly", + "punctual", + "quickly", + "quizzical", + "rare", + "ravenous", + "ready", + "really", + "reckless", + "regular", + "repeated", + "restful", + "rightful", + "rigid", + "rude", + "sadly", + "safely", + "scarce", + "scary", + "searching", + "seeming", + "seldom", + "selfish", + "separate", + "serious", + "shaky", + "sheepish", + "silent", + "sleepy", + "smooth", + "softly", + "solemn", + "solidly", + "sometimes", + "speedy", + "stealthy", + "stern", + "strict", + "stubborn", + "sudden", + "supposed", + "sweetly", + "swiftly", + "tender", + "tensely", + "thankful", + "tight", + "too", + "twice", + "under", + "untrue", + "uphill", + "upward", + "vaguely", + "vainly", + "vastly", + "warmly", + "wearily", + "weekly", + "well", + "wisely", + "within", + "wrongly", + "yonder", +] + +export const verbs = [ + "absorb", + "accept", + "achieve", + "adapt", + "adore", + "advise", + "affirm", + "agree", + "aid", + "aim", + "amaze", + "amuse", + "animate", + "approve", + "arise", + "arrive", + "ascend", + "ask", + "aspire", + "assure", + "attend", + "bake", + "bask", + "beam", + "believe", + "belong", + "bend", + "blend", + "bless", + "blink", + "bloom", + "boil", + "boost", + "borrow", + "breathe", + "bubble", + "build", + "bump", + "burn", + "buy", + "buzz", + "care", + "catch", + "charm", + "cheer", + "cherish", + "chop", + "clap", + "clasp", + "climb", + "clip", + "coax", + "comfort", + "commend", + "compose", + "conquer", + "cook", + "create", + "cry", + "cuddle", + "cure", + "cut", + "dance", + "dare", + "dart", + "dash", + "dazzle", + "delight", + "devour", + "dial", + "dig", + "dine", + "dream", + "drip", + "drop", + "drum", + "dust", + "earn", + "edit", + "embrace", + "emerge", + "empower", + "enchant", + "endure", + "engage", + "enjoy", + "enrich", + "evoke", + "exhale", + "expand", + "explore", + "express", + "fade", + "fall", + "favor", + "fear", + "feast", + "feel", + "fetch", + "file", + "find", + "flip", + "flop", + "flow", + "fold", + "fond", + "forgive", + "foster", + "fry", + "fulfill", + "gasp", + "gaze", + "gleam", + "glow", + "grace", + "grasp", + "greet", + "grin", + "grip", + "grow", + "gulp", + "hack", + "harbor", + "heal", + "heart", + "hike", + "hint", + "honor", + "hope", + "hug", + "hunt", + "hurl", + "hush", + "imagine", + "inspire", + "intend", + "jest", + "jolt", + "jump", + "kick", + "kiss", + "laugh", + "launch", + "lead", + "leap", + "learn", + "lend", + "lift", + "link", + "list", + "lock", + "loop", + "love", + "mend", + "mix", + "mop", + "nail", + "nourish", + "nudge", + "nurture", + "offer", + "pat", + "pause", + "pave", + "peek", + "peel", + "persist", + "pet", + "pick", + "pinch", + "play", + "pop", + "pout", + "praise", + "pray", + "pride", + "promise", + "propel", + "prosper", + "pull", + "push", + "quell", + "quiz", + "race", + "radiate", + "read", + "reap", + "relish", + "renew", + "reside", + "rest", + "revive", + "ripple", + "rise", + "roam", + "roar", + "rush", + "sail", + "savor", + "scold", + "scoop", + "seek", + "sew", + "shine", + "sing", + "skip", + "slide", + "slurp", + "smile", + "snap", + "snip", + "soar", + "spark", + "spin", + "splash", + "sprout", + "spur", + "stab", + "startle", + "stir", + "stop", + "strive", + "succeed", + "support", + "surge", + "sway", + "swim", + "talk", + "tap", + "taste", + "tear", + "tend", + "thrive", + "tickle", + "transform", + "treasure", + "treat", + "trim", + "trip", + "trust", + "twirl", + "twist", + "type", + "urge", + "value", + "vent", + "view", + "walk", + "wave", + "win", + "wish", + "work", + "yell", + "zap", + "zip", + "zoom", +] diff --git a/inlang/source-code/sdk/src/test-utilities/createMessage.test.ts b/inlang/source-code/sdk/src/test-utilities/createMessage.test.ts index ae28cf7834..474748751c 100644 --- a/inlang/source-code/sdk/src/test-utilities/createMessage.test.ts +++ b/inlang/source-code/sdk/src/test-utilities/createMessage.test.ts @@ -6,8 +6,27 @@ test("should create a simple message", () => { createMessage("welcome", { de: "Hallo inlang", }) - ).toMatchInlineSnapshot(` + ).toMatchInlineSnapshot( { + alias: {}, + id: "welcome", + selectors: [], + variants: [ + { + languageTag: "de", + match: [], + pattern: [ + { + type: "Text", + value: "Hallo inlang", + }, + ], + }, + ], + }, + ` + { + "alias": {}, "id": "welcome", "selectors": [], "variants": [ @@ -23,7 +42,8 @@ test("should create a simple message", () => { }, ], } - `) + ` + ) }) test("should create a message with pattern", () => { @@ -35,32 +55,31 @@ test("should create a message with pattern", () => { { type: "Text", value: '"' }, ], }) - ).toMatchInlineSnapshot(` + ).toStrictEqual({ + alias: {}, + id: "greeting", + selectors: [], + variants: [ { - "id": "greeting", - "selectors": [], - "variants": [ - { - "languageTag": "en", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Hi ", - }, - { - "name": "name", - "type": "VariableReference", - }, - { - "type": "Text", - "value": "\\"", - }, - ], - }, - ], - } - `) + languageTag: "en", + match: [], + pattern: [ + { + type: "Text", + value: "Hi ", + }, + { + name: "name", + type: "VariableReference", + }, + { + type: "Text", + value: '"', + }, + ], + }, + ], + }) }) test("should create a message with a pattern", () => { @@ -69,32 +88,31 @@ test("should create a message with a pattern", () => { en: "hello inlang", de: [{ type: "Text", value: "Hallo inlang" }], }) - ).toMatchInlineSnapshot(` + ).toStrictEqual({ + alias: {}, + id: "welcome", + selectors: [], + variants: [ + { + languageTag: "en", + match: [], + pattern: [ + { + type: "Text", + value: "hello inlang", + }, + ], + }, { - "id": "welcome", - "selectors": [], - "variants": [ - { - "languageTag": "en", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "hello inlang", - }, - ], - }, - { - "languageTag": "de", - "match": [], - "pattern": [ - { - "type": "Text", - "value": "Hallo inlang", - }, - ], - }, - ], - } - `) + languageTag: "de", + match: [], + pattern: [ + { + type: "Text", + value: "Hallo inlang", + }, + ], + }, + ], + }) }) diff --git a/inlang/source-code/sdk/src/test-utilities/createMessage.ts b/inlang/source-code/sdk/src/test-utilities/createMessage.ts index dbac98741b..3381694711 100644 --- a/inlang/source-code/sdk/src/test-utilities/createMessage.ts +++ b/inlang/source-code/sdk/src/test-utilities/createMessage.ts @@ -3,6 +3,7 @@ import type { Message, Pattern } from "@inlang/message" export const createMessage = (id: string, patterns: Record) => ({ id, + alias: {}, selectors: [], variants: Object.entries(patterns).map(([languageTag, patterns]) => ({ languageTag, diff --git a/inlang/source-code/sdk/vitest.config.ts b/inlang/source-code/sdk/vitest.config.ts new file mode 100644 index 0000000000..b291e5f288 --- /dev/null +++ b/inlang/source-code/sdk/vitest.config.ts @@ -0,0 +1,8 @@ +import { configDefaults, defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + maxConcurrency: 1, + singleThread: true, + }, +}) diff --git a/inlang/source-code/server/src/main.ts b/inlang/source-code/server/src/main.ts index 3f4cb814a9..fa9f552bc2 100644 --- a/inlang/source-code/server/src/main.ts +++ b/inlang/source-code/server/src/main.ts @@ -49,6 +49,15 @@ if (isProduction) { // ----------------- ROUTES ---------------------- +// used by sdk load test +app.get("/ping", (_, response) => { + response.send( + `http://localhost:${process.env.PORT ?? 3000} ${ + process.env.MOCK_TRANSLATE ? "MOCK_TRANSLATE" : "" + }\n` + ) +}) + const serializedMarketplaceManifest = JSON.stringify(MarketplaceManifest) app.get("/schema/marketplace-manifest", (_, response) => { diff --git a/inlang/source-code/templates/plugin/src/plugin.test.ts b/inlang/source-code/templates/plugin/src/plugin.test.ts index e7c956e2bf..af5d2966e1 100644 --- a/inlang/source-code/templates/plugin/src/plugin.test.ts +++ b/inlang/source-code/templates/plugin/src/plugin.test.ts @@ -3,7 +3,7 @@ import { ProjectSettings, loadProject } from "@inlang/sdk" import { id as pluginId } from "../marketplace-manifest.json" import { mockRepo } from "@lix-js/client" -it("should return fake messages to illustrate how a plugin works", async () => { +it("should return fake messages (without aliases) to illustrate how a plugin works", async () => { // creating a virtual filesystem in a mock repo to store the project file const repo = await mockRepo() const fs = repo.nodeishFs @@ -34,3 +34,36 @@ it("should return fake messages to illustrate how a plugin works", async () => { expect(project.query.messages.get({ where: { id: "this-is-a-test-message" } })).toBeDefined() }) + +it("should return fake messages (with aliases) to illustrate how a plugin works", async () => { + // creating a virtual filesystem in a mock repo to store the project file + const repo = await mockRepo() + const fs = repo.nodeishFs + + // creating a project file + const settings = { + sourceLanguageTag: "en", + modules: ["./plugin.js"], + languageTags: ["en", "de"], + experimental: { aliases: true }, + } satisfies ProjectSettings + + // writing the project file to the virtual filesystem + await fs.mkdir("/project.inlang", { recursive: true }) + await fs.writeFile("/project.inlang/settings.json", JSON.stringify(settings)) + + // opening the project file and loading the plugin + const project = await loadProject({ + repo, + projectPath: "/project.inlang", + // simulate the import function that the SDK uses + // to inject the plugin into the project + _import: async () => import("./index.js"), + }) + + expect(project.errors()).toEqual([]) + + expect(project.installed.plugins()[0]?.id).toBe(pluginId) + + expect(project.query.messages.get({ where: { id: "steep_alpaca_drum_intense" } })).toBeDefined() +}) \ No newline at end of file diff --git a/inlang/source-code/versioned-interfaces/message/src/interface.ts b/inlang/source-code/versioned-interfaces/message/src/interface.ts index 43e1e2f38f..bea1ccc4ff 100644 --- a/inlang/source-code/versioned-interfaces/message/src/interface.ts +++ b/inlang/source-code/versioned-interfaces/message/src/interface.ts @@ -58,6 +58,7 @@ export const Variant = Type.Object({ export type Message = Static export const Message = Type.Object({ id: Type.String(), + alias: Type.Record(Type.String(), Type.String()), /** * The order in which the selectors are placed determines the precedence of patterns. */ diff --git a/inlang/source-code/versioned-interfaces/project-settings/src/interface.ts b/inlang/source-code/versioned-interfaces/project-settings/src/interface.ts index 99a58dbbfc..67dfce62cb 100644 --- a/inlang/source-code/versioned-interfaces/project-settings/src/interface.ts +++ b/inlang/source-code/versioned-interfaces/project-settings/src/interface.ts @@ -76,6 +76,7 @@ const InternalProjectSettings = Type.Object({ ], }) ), + experimental: Type.Optional(Type.Record(Type.String(), Type.Literal(true))), }) /** diff --git a/lix/source-code/client/src/api.ts b/lix/source-code/client/src/api.ts index 67c3f7a393..f8726d5c29 100644 --- a/lix/source-code/client/src/api.ts +++ b/lix/source-code/client/src/api.ts @@ -27,7 +27,7 @@ export type Repository = { }) => Promise> | undefined> push: () => Promise> | undefined> pull: (args: { author: Author; fastForward: boolean; singleBranch: true }) => Promise - add: (args: { filepath: string }) => Promise>> + add: (args: { filepath: string | string[] }) => Promise>> listRemotes: () => Promise> | undefined> log: (args?: { since?: Date; depth?: number }) => Promise>> statusMatrix: (args: { filter: any }) => Promise>> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2af85f6088..d8f679279e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -403,6 +403,9 @@ importers: solid-tiptap: specifier: ^0.6.0 version: 0.6.0(@tiptap/core@2.0.3)(@tiptap/pm@2.0.3)(solid-js@1.7.11) + throttle-debounce: + specifier: ^5.0.0 + version: 5.0.0 tsx: specifier: 3.14.0 version: 3.14.0 @@ -482,6 +485,9 @@ importers: '@types/node': specifier: 20.5.9 version: 20.5.9 + '@types/throttle-debounce': + specifier: ^5.0.2 + version: 5.0.2 autoprefixer: specifier: ^10.4.12 version: 10.4.18(postcss@8.4.35) @@ -1998,12 +2004,18 @@ importers: '@sinclair/typebox': specifier: ^0.31.17 version: 0.31.28 + debug: + specifier: ^4.3.4 + version: 4.3.4(supports-color@8.1.1) dedent: specifier: 1.5.1 version: 1.5.1 deepmerge-ts: specifier: ^5.1.0 version: 5.1.0 + murmurhash3js: + specifier: ^3.0.1 + version: 3.0.1 solid-js: specifier: 1.6.12 version: 1.6.12 @@ -2011,6 +2023,12 @@ importers: specifier: ^5.0.0 version: 5.0.0 devDependencies: + '@types/debug': + specifier: ^4.1.12 + version: 4.1.12 + '@types/murmurhash3js': + specifier: ^3.0.7 + version: 3.0.7 '@types/throttle-debounce': specifier: 5.0.0 version: 5.0.0 @@ -2033,6 +2051,40 @@ importers: specifier: 0.34.6 version: 0.34.6(jsdom@22.1.0) + inlang/source-code/sdk/load-test: + dependencies: + '@inlang/cli': + specifier: workspace:* + version: link:../../cli + '@inlang/sdk': + specifier: workspace:* + version: link:.. + '@lix-js/client': + specifier: workspace:* + version: link:../../../../lix/source-code/client + debug: + specifier: ^4.3.4 + version: 4.3.4(supports-color@8.1.1) + i18next: + specifier: ^23.10.0 + version: 23.10.1 + throttle-debounce: + specifier: ^5.0.0 + version: 5.0.0 + devDependencies: + '@types/debug': + specifier: ^4.1.12 + version: 4.1.12 + '@types/node': + specifier: ^20.11.20 + version: 20.11.26 + '@types/throttle-debounce': + specifier: 5.0.0 + version: 5.0.0 + tsx: + specifier: ^4.7.1 + version: 4.7.1 + inlang/source-code/server: dependencies: '@inlang/badge': @@ -8450,6 +8502,7 @@ packages: /@sindresorhus/is@5.6.0: resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} engines: {node: '>=14.16'} + requiresBuild: true dev: true /@sindresorhus/merge-streams@2.3.0: @@ -9436,6 +9489,7 @@ packages: /@szmarczak/http-timer@5.0.1: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} + requiresBuild: true dependencies: defer-to-connect: 2.0.1 dev: true @@ -9933,7 +9987,6 @@ packages: resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} dependencies: '@types/ms': 0.7.34 - dev: false /@types/detect-port@1.3.5: resolution: {integrity: sha512-Rf3/lB9WkDfIL9eEKaSYKc+1L/rNVYBjThk22JTqQw0YozXarX8YljFAz+HCoC6h4B4KwCMsBPZHaFezwT4BNA==} @@ -10046,6 +10099,7 @@ packages: /@types/http-cache-semantics@4.0.4: resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + requiresBuild: true dev: true /@types/http-errors@2.0.4: @@ -10161,7 +10215,10 @@ packages: /@types/ms@0.7.34: resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} - dev: false + + /@types/murmurhash3js@3.0.7: + resolution: {integrity: sha512-jN3Z37nILIW1DZyP6N/NK+aw/zjFHPVb7hjrmdw7jx7FayrhKgkNpo6ZDwAsH8HSANjebBOxoXXtA39gKwyeGw==} + dev: true /@types/nlcst@1.0.4: resolution: {integrity: sha512-ABoYdNQ/kBSsLvZAekMhIPMQ3YUZvavStpKYs7BjLLuKVmIMA0LUgZ7b54zzuWJRbHF80v1cNf4r90Vd6eMQDg==} @@ -12773,11 +12830,13 @@ packages: /cacheable-lookup@7.0.0: resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} engines: {node: '>=14.16'} + requiresBuild: true dev: true /cacheable-request@10.2.14: resolution: {integrity: sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==} engines: {node: '>=14.16'} + requiresBuild: true dependencies: '@types/http-cache-semantics': 4.0.4 get-stream: 6.0.1 @@ -13965,6 +14024,7 @@ packages: /defer-to-connect@2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} + requiresBuild: true dev: true /define-data-property@1.1.4: @@ -15933,6 +15993,7 @@ packages: /form-data-encoder@2.1.4: resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} engines: {node: '>= 14.17'} + requiresBuild: true dev: true /form-data@4.0.0: @@ -16405,6 +16466,7 @@ packages: /got@12.6.1: resolution: {integrity: sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==} engines: {node: '>=14.16'} + requiresBuild: true dependencies: '@sindresorhus/is': 5.6.0 '@szmarczak/http-timer': 5.0.1 @@ -17062,6 +17124,7 @@ packages: /http2-wrapper@2.2.1: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} + requiresBuild: true dependencies: quick-lru: 5.1.1 resolve-alpn: 1.2.1 @@ -17134,6 +17197,12 @@ packages: engines: {node: '>=10.18'} dev: true + /i18next@23.10.1: + resolution: {integrity: sha512-NDiIzFbcs3O9PXpfhkjyf7WdqFn5Vq6mhzhtkXzj51aOcNuPNcTwuYNuXCpHsanZGHlHKL35G7huoFeVic1hng==} + dependencies: + '@babel/runtime': 7.24.0 + dev: false + /iconify-icon@1.0.8: resolution: {integrity: sha512-jvbUKHXf8EnGGArmhlP2IG8VqQLFFyTvTqb9LVL2TKTh7/eCCD1o2HHE9thpbJJb6B8hzhcFb6rOKhvo7reNKA==} dependencies: @@ -18278,6 +18347,7 @@ packages: /ky@0.33.3: resolution: {integrity: sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw==} engines: {node: '>=14.16'} + requiresBuild: true dev: true /language-subtag-registry@0.3.22: @@ -18602,11 +18672,13 @@ packages: /loglevel-plugin-prefix@0.8.4: resolution: {integrity: sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==} + requiresBuild: true dev: true /loglevel@1.9.1: resolution: {integrity: sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==} engines: {node: '>= 0.6.0'} + requiresBuild: true dev: true /longest-streak@3.1.0: @@ -18642,6 +18714,7 @@ packages: /lowercase-keys@3.0.0: resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + requiresBuild: true dev: true /lowlight@3.1.0: @@ -19907,6 +19980,7 @@ packages: /mimic-response@4.0.0: resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + requiresBuild: true dev: true /min-indent@1.0.1: @@ -20101,6 +20175,11 @@ packages: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} dev: false + /murmurhash3js@3.0.1: + resolution: {integrity: sha512-KL8QYUaxq7kUbcl0Yto51rMcYt7E/4N4BG3/c96Iqw1PQrTRspu8Cpx4TZ4Nunib1d4bEkIH3gjCYlP2RLBdow==} + engines: {node: '>=0.10.0'} + dev: false + /mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} dev: true @@ -20490,6 +20569,7 @@ packages: /normalize-url@8.0.1: resolution: {integrity: sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==} engines: {node: '>=14.16'} + requiresBuild: true dev: true /not@0.1.0: @@ -20956,6 +21036,7 @@ packages: /p-cancelable@3.0.0: resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} engines: {node: '>=12.20'} + requiresBuild: true dev: true /p-event@2.3.1: @@ -22106,6 +22187,7 @@ packages: /quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + requiresBuild: true dev: true /quill-delta@5.1.0: @@ -22721,6 +22803,7 @@ packages: /resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + requiresBuild: true dev: true /resolve-cwd@3.0.0: @@ -22773,6 +22856,7 @@ packages: /responselike@3.0.0: resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} engines: {node: '>=14.16'} + requiresBuild: true dependencies: lowercase-keys: 3.0.0 dev: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2ac44b15e4..7850426824 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,4 +9,5 @@ packages: - 'inlang/source-code/paraglide/paraglide-js-adapter-next/examples/*' - 'inlang/source-code/templates/*' - 'inlang/source-code/message-lint-rules/*' - - 'inlang/source-code/versioned-interfaces/*' \ No newline at end of file + - 'inlang/source-code/versioned-interfaces/*' + - 'inlang/source-code/sdk/load-test' \ No newline at end of file diff --git a/project.inlang/settings.json b/project.inlang/settings.json index af32c39daf..3e68d6c1f5 100644 --- a/project.inlang/settings.json +++ b/project.inlang/settings.json @@ -17,5 +17,8 @@ "messageLintRuleLevels": { "messageLintRule.inlang.missingTranslation": "error", "messageLintRule.inlang.validJsIdentifier": "error" + }, + "experimental": { + "aliases": true } } diff --git a/render.yaml b/render.yaml index d3e73e290a..ef5dab9dc8 100644 --- a/render.yaml +++ b/render.yaml @@ -28,6 +28,8 @@ services: runtime: node region: frankfurt plan: starter + # PR preview deployments use the shared production git-proxy + # This disables branch previews for the git-proxy branch: main buildCommand: pnpm install && pnpm run build startCommand: pnpm --filter @lix-js/server production