Compare commits

...

2 Commits

Author SHA1 Message Date
comfy-pr-bot
f3466ba08b [release] Increment version to 1.46.16 2026-07-15 04:43:25 +00:00
Christian Byrne
5de30e6745 [backport core/1.46] fix(i18n): escape vue-i18n message syntax in generated node defs + preserve raw messages on compile errors (#13631) (#13658)
Backport of #13631 to `core/1.46`.

The auto-backport didn't fire for `core/1.46` because the label was
added post-merge (this mirrors the existing #13651 core/1.47 and #13652
cloud/1.47 backports of the same source PR).

## Crash it fixes
`core/1.46` (1.46.15) ships the same "Invalid linked format" crash:
`src/locales/en/nodeDefs.json` contains `@subject{subject_id}`, which
vue-i18n parses as an invalid linked message and crashes the app on
load.

The fix is defense-in-depth:
- **Generation-time escaping** — `escapeVueI18nMessageSyntax()` escapes
vue-i18n message-syntax chars (`@ { } | %`) in fields compiled via
`t()`/`st()` when collecting node defs, so the locale bundle is always
valid.
- **Runtime guard** — any vue-i18n compile error (custom nodes,
already-shipped bundles) degrades to the raw message instead of
crashing.

## Why now
This must land on `core/1.46` before it can be GA'd. It unblocks the
held ComfyUI bump (supersedes stale #14801) — 1.46 GA will become
1.46.16.

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: xmarre <54859656+xmarre@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:48:55 -07:00
7 changed files with 239 additions and 15 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.46.15",
"version": "1.46.16",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -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('')
})
})
})

View File

@@ -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

View File

@@ -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)
])
)
)

View File

@@ -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<string, unknown>)[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'
)
})
})

View File

@@ -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)
}

View File

@@ -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)
})
})