From 28dd05ae3d126bc4f09d0ca2a01a3fd97bf06fdf Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Wed, 15 Jul 2026 00:04:28 -0400 Subject: [PATCH] [backport cloud/1.45] fix(i18n): escape vue-i18n message syntax in generated node defs + preserve raw messages on compile errors (#13631) (#13676) Co-authored-by: Christian Byrne --- .../src/formatUtil.test.ts | 46 +++++++++++ .../shared-frontend-utils/src/formatUtil.ts | 34 ++++++++ scripts/collect-i18n-node-defs.ts | 37 ++++++--- src/i18n.safeTranslation.test.ts | 80 +++++++++++++++++++ src/i18n.ts | 20 ++++- src/locales/escapeNodeDefI18n.test.ts | 35 ++++++++ 6 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 src/i18n.safeTranslation.test.ts create mode 100644 src/locales/escapeNodeDefI18n.test.ts diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts index 001706d0ea..10c3b1594c 100644 --- a/packages/shared-frontend-utils/src/formatUtil.test.ts +++ b/packages/shared-frontend-utils/src/formatUtil.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { appendWorkflowJsonExt, ensureWorkflowSuffix, + escapeVueI18nMessageSyntax, formatLocalizedMediumDate, formatLocalizedNumber, getFilePathSeparatorVariants, @@ -476,4 +477,49 @@ describe('formatUtil', () => { expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—') }) }) + + describe('escapeVueI18nMessageSyntax', () => { + it('escapes a literal @ that would break linked-message compilation', () => { + expect( + escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)') + ).toBe("clips (tagged {'@'}Audio1-3 in the prompt)") + }) + + it('escapes @ in an email address', () => { + expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe( + "support{'@'}comfy.org" + ) + }) + + it('escapes interpolation braces', () => { + expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe( + "size {'{'}w{'}'}x{'{'}h{'}'}" + ) + }) + + it('escapes the plural pipe separator', () => { + expect(escapeVueI18nMessageSyntax('foreground | background')).toBe( + "foreground {'|'} background" + ) + }) + + it('escapes the modulo percent so it cannot re-form %{', () => { + expect(escapeVueI18nMessageSyntax('50%{done}')).toBe( + "50{'%'}{'{'}done{'}'}" + ) + }) + + it('escapes every occurrence in a single pass', () => { + expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe( + "{'@'}a {'@'}b {'@'}c" + ) + }) + + it('leaves strings without syntax characters unchanged', () => { + expect(escapeVueI18nMessageSyntax('no special chars here')).toBe( + 'no special chars here' + ) + expect(escapeVueI18nMessageSyntax('')).toBe('') + }) + }) }) diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts index 9e6258295a..af1d525c5d 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -178,6 +178,40 @@ export function normalizeI18nKey(key: string) { return typeof key === 'string' ? key.replace(/\./g, '_') : '' } +/** + * Characters that vue-i18n's message compiler treats as syntax in message text, + * so plain text has to escape them to render verbatim through `t()`/`st()`: + * + * - `@` starts a linked-message reference (`@:key`); malformed usage throws + * `Invalid linked format`. + * - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced + * brace throws `Unterminated/Unbalanced closing brace`. + * - `|` separates plural branches, so `a | b` silently renders as one branch. + * - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`); + * it must be escaped too, otherwise escaping a following `{` re-forms `%{`. + * + * The set is a build-inlined `const enum` (`TokenChars`) in + * `@intlify/message-compiler` and is not exported, so it is hardcoded here. + * + * @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation) + * @see https://vue-i18n.intlify.dev/guide/essentials/pluralization + */ +const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g + +/** + * Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`) + * so arbitrary text renders verbatim instead of being parsed as syntax. This is + * the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}. + * + * Only apply to values read through the compiler (`t()`/`st()`). Values read raw + * via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the + * literal `{'x'}` would surface to users. Apply exactly once to raw text: the + * escape output itself contains `{`/`}`, so it is not idempotent. + */ +export function escapeVueI18nMessageSyntax(text: string): string { + return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`) +} + /** * Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments. * @param input The dynamic prompt to process diff --git a/scripts/collect-i18n-node-defs.ts b/scripts/collect-i18n-node-defs.ts index 46d85ad734..e18604e11b 100644 --- a/scripts/collect-i18n-node-defs.ts +++ b/scripts/collect-i18n-node-defs.ts @@ -3,7 +3,10 @@ import * as fs from 'fs' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage' -import { normalizeI18nKey } from '../packages/shared-frontend-utils/src/formatUtil' +import { + escapeVueI18nMessageSyntax, + normalizeI18nKey +} from '@/utils/formatUtil' import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore' const localePath = './src/locales/en/main.json' @@ -44,8 +47,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { } ) - console.log(`Collected ${nodeDefs.length} node definitions`) - const allDataTypesLocale = Object.fromEntries( nodeDefs .flatMap((nodeDef) => { @@ -60,7 +61,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { ) return allDataTypes.map((dataType) => [ normalizeI18nKey(dataType), - dataType + escapeVueI18nMessageSyntax(dataType) ]) }) .sort((a, b) => a[0].localeCompare(b[0])) @@ -98,7 +99,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { const runtimeWidgets = Object.fromEntries( Object.entries(widgetsMappings) .sort((a, b) => a[0].localeCompare(b[0])) - .map(([key, value]) => [normalizeI18nKey(key), { name: value }]) + .map(([key, value]) => [ + normalizeI18nKey(key), + { name: value ? escapeVueI18nMessageSyntax(value) : value } + ]) ) if (Object.keys(runtimeWidgets).length > 0) { @@ -121,7 +125,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { function extractInputs(nodeDef: ComfyNodeDefImpl) { const inputs = Object.fromEntries( Object.values(nodeDef.inputs).flatMap((input) => { - const name = input.name + const name = + input.name === undefined + ? undefined + : escapeVueI18nMessageSyntax(input.name) const tooltip = input.tooltip if (name === undefined && tooltip === undefined) { @@ -146,7 +153,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { const outputs = Object.fromEntries( nodeDef.outputs.flatMap((output, i) => { // Ignore data types if they are already translated in allDataTypesLocale. - const name = output.name in allDataTypesLocale ? undefined : output.name + const name = + output.name === undefined || output.name in allDataTypesLocale + ? undefined + : escapeVueI18nMessageSyntax(output.name) const tooltip = output.tooltip if (name === undefined && tooltip === undefined) { @@ -179,8 +189,12 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { return [ normalizeI18nKey(nodeDef.name), { - display_name: nodeDef.display_name ?? nodeDef.name, - description: nodeDef.description || undefined, + display_name: escapeVueI18nMessageSyntax( + nodeDef.display_name ?? nodeDef.name + ), + description: nodeDef.description + ? escapeVueI18nMessageSyntax(nodeDef.description) + : undefined, inputs: Object.keys(inputs).length > 0 ? inputs : undefined, outputs: extractOutputs(nodeDef) } @@ -192,7 +206,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { nodeDefs.flatMap((nodeDef) => nodeDef.category .split('/') - .map((category) => [normalizeI18nKey(category), category]) + .map((category) => [ + normalizeI18nKey(category), + escapeVueI18nMessageSyntax(category) + ]) ) ) diff --git a/src/i18n.safeTranslation.test.ts b/src/i18n.safeTranslation.test.ts new file mode 100644 index 0000000000..5b29bbc13c --- /dev/null +++ b/src/i18n.safeTranslation.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { i18n, st, stRaw } from './i18n' + +const TEST_NAMESPACE = 'safeTranslationTest' + +beforeEach(() => { + i18n.global.locale.value = 'en' + const messages = i18n.global.getLocaleMessage('en') + delete (messages as Record)[TEST_NAMESPACE] + i18n.global.setLocaleMessage('en', messages) +}) + +describe('st', () => { + it('returns the fallback when the key is not found', () => { + expect(st('safeTranslationTest.missing', 'Fallback value')).toBe( + 'Fallback value' + ) + }) + + it('uses compiled translations for valid locale messages', () => { + i18n.global.mergeLocaleMessage('en', { + safeTranslationTest: { + valid: 'Translated value' + } + }) + + expect(st('safeTranslationTest.valid', 'Fallback value')).toBe( + 'Translated value' + ) + }) + + it('returns raw locale messages when vue-i18n compilation fails', () => { + const message = 'Provided by @acme/model with JSON such as {"mode":"fast"}' + + i18n.global.mergeLocaleMessage('en', { + safeTranslationTest: { + invalidLinkedFormat: message + } + }) + + expect( + st('safeTranslationTest.invalidLinkedFormat', 'Fallback value') + ).toBe(message) + }) +}) + +describe('stRaw', () => { + it('returns raw locale messages for valid keys', () => { + i18n.global.mergeLocaleMessage('en', { + safeTranslationTest: { + rawValue: 'Raw value' + } + }) + + expect(stRaw('safeTranslationTest.rawValue', 'Fallback value')).toBe( + 'Raw value' + ) + }) + + it('returns raw messages containing vue-i18n syntax', () => { + const message = 'Provided by @acme/model with JSON such as {"mode":"fast"}' + + i18n.global.mergeLocaleMessage('en', { + safeTranslationTest: { + rawSyntax: message + } + }) + + expect(stRaw('safeTranslationTest.rawSyntax', 'Fallback value')).toBe( + message + ) + }) + + it('returns the fallback when the key is not found', () => { + expect(stRaw('safeTranslationTest.rawMissing', 'Fallback value')).toBe( + 'Fallback value' + ) + }) +}) diff --git a/src/i18n.ts b/src/i18n.ts index e52ec704d4..e7b0160ce7 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -157,15 +157,28 @@ export const i18n = createI18n({ export const { t, te, d } = i18n.global const { tm } = i18n.global +function rawTranslationOrFallback(key: string, fallbackMessage: string) { + const message = tm(key) + return typeof message === 'string' ? message : fallbackMessage +} + /** * Safe translation function that returns the fallback message if the key is not found. + * Invalid message syntax falls back to the raw locale message instead of crashing. * * @param key - The key to translate. * @param fallbackMessage - The fallback message to use if the key is not found. */ export function st(key: string, fallbackMessage: string) { - // The normal defaultMsg overload fails in some cases for custom nodes - return te(key) ? t(key) : fallbackMessage + if (!te(key)) return fallbackMessage + + try { + // The normal defaultMsg overload fails in some cases for custom nodes + return t(key) + } catch (error) { + if (!(error instanceof SyntaxError)) throw error + return rawTranslationOrFallback(key, fallbackMessage) + } } /** @@ -178,6 +191,5 @@ export function st(key: string, fallbackMessage: string) { export function stRaw(key: string, fallbackMessage: string) { if (!te(key)) return fallbackMessage - const message = tm(key) - return typeof message === 'string' ? message : fallbackMessage + return rawTranslationOrFallback(key, fallbackMessage) } diff --git a/src/locales/escapeNodeDefI18n.test.ts b/src/locales/escapeNodeDefI18n.test.ts new file mode 100644 index 0000000000..1edeccbeb2 --- /dev/null +++ b/src/locales/escapeNodeDefI18n.test.ts @@ -0,0 +1,35 @@ +import { createI18n } from 'vue-i18n' +import { describe, expect, it } from 'vitest' + +import { escapeVueI18nMessageSyntax } from '@comfyorg/shared-frontend-utils/formatUtil' + +/** + * Node descriptions are compiled by vue-i18n via `t()`/`st()`, which parses + * `@ { } | %` as message syntax — a literal `@` even crashes the compiler with + * `Invalid linked format` (this broke the whole app after the 1.47.7 locale + * sync). `collect-i18n-node-defs.ts` escapes such values with + * `escapeVueI18nMessageSyntax` before writing them; this guards that the escaped + * output actually compiles and renders the original literal text. + */ +describe('escapeVueI18nMessageSyntax output is compiled safely by vue-i18n', () => { + const compile = (message: string) => { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: { value: message } } + }) + return i18n.global.t('value') + } + + it.for([ + 'clips (tagged @Audio1-3 in the prompt)', + 'support@comfy.org', + 'resolution {width}x{height}', + 'foreground | background', + '50%{done}', + 'all of @ { } | % together', + 'no special chars here' + ])('renders %s as the original literal text', (raw) => { + expect(compile(escapeVueI18nMessageSyntax(raw))).toBe(raw) + }) +})