From dfac4e0b3130abd5693fdca87e090540adc21f88 Mon Sep 17 00:00:00 2001 From: DrJKL Date: Tue, 14 Jul 2026 12:01:52 -0700 Subject: [PATCH] refactor: isolate node locale serialization --- .../src/formatUtil.test.ts | 46 ------ .../shared-frontend-utils/src/formatUtil.ts | 34 ----- scripts/collect-i18n-node-defs.ts | 135 ++---------------- scripts/nodeDefLocaleSerializer.test.ts | 129 +++++++++++++++++ scripts/nodeDefLocaleSerializer.ts | 127 ++++++++++++++++ src/locales/escapeNodeDefI18n.test.ts | 35 ----- 6 files changed, 264 insertions(+), 242 deletions(-) create mode 100644 scripts/nodeDefLocaleSerializer.test.ts create mode 100644 scripts/nodeDefLocaleSerializer.ts delete 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 fa72cd3a40..4ee57be4cf 100644 --- a/packages/shared-frontend-utils/src/formatUtil.test.ts +++ b/packages/shared-frontend-utils/src/formatUtil.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest' import { appendWorkflowJsonExt, ensureWorkflowSuffix, - escapeVueI18nMessageSyntax, formatLocalizedMediumDate, formatLocalizedNumber, getFilePathSeparatorVariants, @@ -478,49 +477,4 @@ 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 95aa50692d..e3cb7e1261 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -178,40 +178,6 @@ 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 e18604e11b..cb2818602f 100644 --- a/scripts/collect-i18n-node-defs.ts +++ b/scripts/collect-i18n-node-defs.ts @@ -3,11 +3,9 @@ import * as fs from 'fs' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage' -import { - escapeVueI18nMessageSyntax, - normalizeI18nKey -} from '@/utils/formatUtil' import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore' +import type { WidgetLabels } from './nodeDefLocaleSerializer' +import { serializeNodeDefLocales } from './nodeDefLocaleSerializer' const localePath = './src/locales/en/main.json' const nodeDefsPath = './src/locales/en/nodeDefs.json' @@ -17,10 +15,6 @@ interface WidgetInfo { label?: string } -interface WidgetLabels { - [key: string]: Record -} - test('collect-i18n-node-defs', async ({ comfyPage }) => { // Mock view route await comfyPage.page.route('**/view**', async (route) => { @@ -47,26 +41,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { } ) - const allDataTypesLocale = Object.fromEntries( - nodeDefs - .flatMap((nodeDef) => { - const inputDataTypes = Object.values(nodeDef.inputs).map( - (inputSpec) => inputSpec.type - ) - const outputDataTypes = nodeDef.outputs.map( - (outputSpec) => outputSpec.type - ) - const allDataTypes = [...inputDataTypes, ...outputDataTypes].flatMap( - (type: string) => type.split(',') - ) - return allDataTypes.map((dataType) => [ - normalizeI18nKey(dataType), - escapeVueI18nMessageSyntax(dataType) - ]) - }) - .sort((a, b) => a[0].localeCompare(b[0])) - ) - async function extractWidgetLabels() { const nodeLabels: WidgetLabels = {} @@ -95,14 +69,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { [nodeDef.name, nodeDef.display_name, inputNames] ) - // Format runtime widgets const runtimeWidgets = Object.fromEntries( Object.entries(widgetsMappings) .sort((a, b) => a[0].localeCompare(b[0])) - .map(([key, value]) => [ - normalizeI18nKey(key), - { name: value ? escapeVueI18nMessageSyntax(value) : value } - ]) + .map(([key, name]) => [key, { name }]) ) if (Object.keys(runtimeWidgets).length > 0) { @@ -121,97 +91,8 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { } const nodeDefLabels = await extractWidgetLabels() - - function extractInputs(nodeDef: ComfyNodeDefImpl) { - const inputs = Object.fromEntries( - Object.values(nodeDef.inputs).flatMap((input) => { - const name = - input.name === undefined - ? undefined - : escapeVueI18nMessageSyntax(input.name) - const tooltip = input.tooltip - - if (name === undefined && tooltip === undefined) { - return [] - } - - return [ - [ - normalizeI18nKey(input.name), - { - name, - tooltip - } - ] - ] - }) - ) - return Object.keys(inputs).length > 0 ? inputs : undefined - } - - function extractOutputs(nodeDef: ComfyNodeDefImpl) { - const outputs = Object.fromEntries( - nodeDef.outputs.flatMap((output, i) => { - // Ignore data types if they are already translated in allDataTypesLocale. - const name = - output.name === undefined || output.name in allDataTypesLocale - ? undefined - : escapeVueI18nMessageSyntax(output.name) - const tooltip = output.tooltip - - if (name === undefined && tooltip === undefined) { - return [] - } - - return [ - [ - i.toString(), - { - name, - tooltip - } - ] - ] - }) - ) - return Object.keys(outputs).length > 0 ? outputs : undefined - } - - const allNodeDefsLocale = Object.fromEntries( - nodeDefs - .sort((a, b) => a.name.localeCompare(b.name)) - .map((nodeDef) => { - const inputs = { - ...extractInputs(nodeDef), - ...(nodeDefLabels[nodeDef.name] ?? {}) - } - - return [ - normalizeI18nKey(nodeDef.name), - { - 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) - } - ] - }) - ) - - const allNodeCategoriesLocale = Object.fromEntries( - nodeDefs.flatMap((nodeDef) => - nodeDef.category - .split('/') - .map((category) => [ - normalizeI18nKey(category), - escapeVueI18nMessageSyntax(category) - ]) - ) - ) + const { dataTypes, nodeCategories, nodeDefinitions } = + serializeNodeDefLocales(nodeDefs, nodeDefLabels) const locale = JSON.parse(fs.readFileSync(localePath, 'utf-8')) fs.writeFileSync( @@ -219,13 +100,13 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { JSON.stringify( { ...locale, - dataTypes: allDataTypesLocale, - nodeCategories: allNodeCategoriesLocale + dataTypes, + nodeCategories }, null, 2 ) ) - fs.writeFileSync(nodeDefsPath, JSON.stringify(allNodeDefsLocale, null, 2)) + fs.writeFileSync(nodeDefsPath, JSON.stringify(nodeDefinitions, null, 2)) }) diff --git a/scripts/nodeDefLocaleSerializer.test.ts b/scripts/nodeDefLocaleSerializer.test.ts new file mode 100644 index 0000000000..b1b5849b25 --- /dev/null +++ b/scripts/nodeDefLocaleSerializer.test.ts @@ -0,0 +1,129 @@ +import { createI18n } from 'vue-i18n' +import { describe, expect, it } from 'vitest' + +import { serializeNodeDefLocales } from './nodeDefLocaleSerializer' + +function render(message: string): string { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: { value: message } } + }) + return i18n.global.t('value') +} + +describe('serializeNodeDefLocales', () => { + it('escapes compiled fields and preserves raw tooltips', () => { + const syntax = '@ {value} | 50%{done}' + const inputName = `Input ${syntax}` + const outputName = `Output ${syntax}` + const dataType = `TYPE ${syntax}` + const category = `Category ${syntax}` + const nodeDef = { + name: 'Test.Node', + display_name: `Display ${syntax}`, + description: `Description ${syntax}`, + category, + inputs: { + input: { + name: inputName, + type: dataType, + tooltip: `Input tooltip ${syntax}` + } + }, + outputs: [ + { + name: outputName, + type: 'OTHER', + tooltip: `Output tooltip ${syntax}` + } + ] + } + + const { dataTypes, nodeCategories, nodeDefinitions } = + serializeNodeDefLocales([nodeDef], { + 'Test.Node': { + 'Runtime.Widget': { name: `Widget ${syntax}` } + } + }) + const serializedNode = nodeDefinitions.Test_Node + const serializedInput = serializedNode.inputs['Input @ {value} | 50%{done}'] + const serializedOutput = serializedNode.outputs['0'] + + expect(render(serializedNode.display_name)).toBe(nodeDef.display_name) + expect(render(serializedNode.description)).toBe(nodeDef.description) + expect(render(serializedInput.name)).toBe(inputName) + expect(render(serializedOutput.name)).toBe(outputName) + expect(render(serializedNode.inputs.Runtime_Widget.name)).toBe( + `Widget ${syntax}` + ) + expect(render(dataTypes[dataType])).toBe(dataType) + expect(render(nodeCategories[category])).toBe(category) + expect(serializedInput.tooltip).toBe(nodeDef.inputs.input.tooltip) + expect(serializedOutput.tooltip).toBe(nodeDef.outputs[0].tooltip) + }) + + it('preserves locale shapes and ordering', () => { + const { dataTypes, nodeCategories, nodeDefinitions } = + serializeNodeDefLocales( + [ + { + name: 'Z.Node', + description: '', + category: 'group/sub.group', + inputs: { + omitted: { type: 'Z.TYPE' }, + tooltipOnly: { type: 'A_TYPE', tooltip: 'raw @ tooltip' } + }, + outputs: [ + { name: 'A_TYPE', type: 'A_TYPE' }, + { name: 'Custom.Output', type: 'Z.TYPE' }, + { tooltip: 'raw output @ tooltip', type: 'Z.TYPE' } + ] + }, + { + name: 'A.Node', + category: 'group', + inputs: {}, + outputs: [] + } + ], + { + 'Z.Node': { + 'Runtime.Widget': { name: 'Runtime.Label' } + } + } + ) + + expect(dataTypes).toEqual({ + A_TYPE: 'A_TYPE', + Z_TYPE: 'Z.TYPE' + }) + expect(nodeCategories).toEqual({ + group: 'group', + sub_group: 'sub.group' + }) + expect(nodeDefinitions).toEqual({ + A_Node: { + display_name: 'A.Node', + description: undefined, + inputs: undefined, + outputs: undefined + }, + Z_Node: { + display_name: 'Z.Node', + description: undefined, + inputs: { + '': { name: undefined, tooltip: 'raw @ tooltip' }, + Runtime_Widget: { name: 'Runtime.Label' } + }, + outputs: { + 1: { name: 'Custom.Output', tooltip: undefined }, + 2: { name: undefined, tooltip: 'raw output @ tooltip' } + } + } + }) + expect(Object.keys(dataTypes)).toEqual(['A_TYPE', 'Z_TYPE']) + expect(Object.keys(nodeDefinitions)).toEqual(['A_Node', 'Z_Node']) + }) +}) diff --git a/scripts/nodeDefLocaleSerializer.ts b/scripts/nodeDefLocaleSerializer.ts new file mode 100644 index 0000000000..724469ee51 --- /dev/null +++ b/scripts/nodeDefLocaleSerializer.ts @@ -0,0 +1,127 @@ +import { normalizeI18nKey } from '@/utils/formatUtil' + +interface LocalizableInput { + type: string + name?: string + tooltip?: string +} + +interface LocalizableOutput { + type: string + name?: string + tooltip?: string +} + +interface LocalizableNodeDef { + category: string + inputs: Record + name: string + outputs: LocalizableOutput[] + description?: string + display_name?: string +} + +export type WidgetLabels = Record< + string, + Record +> + +const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g + +function escapeMessage(text: string): string { + return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`) +} + +export function serializeNodeDefLocales( + nodeDefs: readonly LocalizableNodeDef[], + widgetLabels: WidgetLabels = {} +) { + const dataTypes = Object.fromEntries( + nodeDefs + .flatMap((nodeDef) => [ + ...Object.values(nodeDef.inputs).map(({ type }) => type), + ...nodeDef.outputs.map(({ type }) => type) + ]) + .flatMap((type) => type.split(',')) + .map((dataType) => [normalizeI18nKey(dataType), escapeMessage(dataType)]) + .sort((a, b) => a[0].localeCompare(b[0])) + ) + + function serializeInputs(nodeDef: LocalizableNodeDef) { + const inputs = Object.fromEntries( + Object.values(nodeDef.inputs).flatMap(({ name, tooltip }) => { + if (name === undefined && tooltip === undefined) return [] + + return [ + [ + normalizeI18nKey(name ?? ''), + { + name: name === undefined ? undefined : escapeMessage(name), + tooltip + } + ] + ] + }) + ) + return Object.keys(inputs).length > 0 ? inputs : undefined + } + + function serializeOutputs(nodeDef: LocalizableNodeDef) { + const outputs = Object.fromEntries( + nodeDef.outputs.flatMap(({ name, tooltip }, index) => { + const serializedName = + name === undefined || name in dataTypes + ? undefined + : escapeMessage(name) + if (serializedName === undefined && tooltip === undefined) return [] + + return [[index.toString(), { name: serializedName, tooltip }]] + }) + ) + return Object.keys(outputs).length > 0 ? outputs : undefined + } + + function serializeWidgetLabels(nodeName: string) { + return Object.fromEntries( + Object.entries(widgetLabels[nodeName] ?? {}).map(([name, label]) => [ + normalizeI18nKey(name), + { + name: label.name === undefined ? undefined : escapeMessage(label.name) + } + ]) + ) + } + + const nodeDefinitions = Object.fromEntries( + [...nodeDefs] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((nodeDef) => { + const inputs = { + ...serializeInputs(nodeDef), + ...serializeWidgetLabels(nodeDef.name) + } + + return [ + normalizeI18nKey(nodeDef.name), + { + display_name: escapeMessage(nodeDef.display_name ?? nodeDef.name), + description: nodeDef.description + ? escapeMessage(nodeDef.description) + : undefined, + inputs: Object.keys(inputs).length > 0 ? inputs : undefined, + outputs: serializeOutputs(nodeDef) + } + ] + }) + ) + + const nodeCategories = Object.fromEntries( + nodeDefs.flatMap(({ category }) => + category + .split('/') + .map((part) => [normalizeI18nKey(part), escapeMessage(part)]) + ) + ) + + return { dataTypes, nodeCategories, nodeDefinitions } +} diff --git a/src/locales/escapeNodeDefI18n.test.ts b/src/locales/escapeNodeDefI18n.test.ts deleted file mode 100644 index 1edeccbeb2..0000000000 --- a/src/locales/escapeNodeDefI18n.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -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) - }) -})