mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
Compare commits
5 Commits
updates_to
...
chore/code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee2a2b1b07 | ||
|
|
a154e6a311 | ||
|
|
46fec1d47d | ||
|
|
1052658a02 | ||
|
|
f524683f3c |
@@ -60,5 +60,7 @@
|
||||
.claude/
|
||||
.cursor/
|
||||
.cursorrules
|
||||
**/AGENTS.md
|
||||
**/CLAUDE.md
|
||||
|
||||
# Agent instructions
|
||||
**/AGENTS.md @christian-byrne
|
||||
|
||||
@@ -10,11 +10,13 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
|
||||
const LOCALE_PREFIXES = LOCALES.map((locale) =>
|
||||
locale === DEFAULT_LOCALE ? '' : `/${locale}`
|
||||
)
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
|
||||
LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set([
|
||||
...LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
|
||||
)
|
||||
)
|
||||
),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/individual-submission`),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/booking-confirmation`)
|
||||
])
|
||||
|
||||
function isExcludedFromSitemap(page: string): boolean {
|
||||
const pathname = new URL(page).pathname.replace(/\/$/, '')
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
const { resources } = defineProps<{
|
||||
resources: { label: string; href: string; display: string }[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex flex-col gap-3 text-base font-light lg:text-lg">
|
||||
<li v-for="resource in resources" :key="resource.href">
|
||||
<span class="text-primary-comfy-canvas">{{ resource.label }}</span>
|
||||
<span class="text-primary-warm-gray"> — </span>
|
||||
<a
|
||||
:href="resource.href"
|
||||
class="text-primary-comfy-yellow underline underline-offset-4 hover:no-underline"
|
||||
>
|
||||
{{ resource.display }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { href, label } = defineProps<{
|
||||
href: string
|
||||
label: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-2 flex justify-center">
|
||||
<Button as="a" :href variant="default" size="lg">
|
||||
{{ label }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,12 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
const { title } = defineProps<{ title: string }>()
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { title, class: className } = defineProps<{
|
||||
title: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex items-center justify-center px-6 pt-20 pb-16 lg:pt-32 lg:pb-24"
|
||||
>
|
||||
<h1 class="text-primary-comfy-canvas text-4xl font-light lg:text-6xl">
|
||||
<h1
|
||||
:class="
|
||||
cn(
|
||||
'text-4xl font-light text-primary-comfy-canvas lg:text-6xl',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
54
apps/website/src/pages/booking-confirmation.astro
Normal file
54
apps/website/src/pages/booking-confirmation.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: 'Learning Center',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: 'Workflow templates',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: 'Customer stories',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="You're booked - Comfy"
|
||||
description="Your meeting is booked. Check your email for the calendar invite and meeting link."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="You're booked." class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>Check your email for the calendar invite and meeting link!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
Resources while you wait
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
60
apps/website/src/pages/individual-submission.astro
Normal file
60
apps/website/src/pages/individual-submission.astro
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import PlansPricingCta from '../components/individual-submission/PlansPricingCta.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { getRoutes } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Thanks for reaching out - Comfy"
|
||||
description="Thanks for reaching out. Based on what you shared, one of our self-serve plans is probably a better fit."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="Thanks for reaching out." class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>
|
||||
Based on what you shared, one of our self-serve plans is probably a
|
||||
better fit.
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Standard</strong
|
||||
>,
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Creator</strong
|
||||
>, and
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Pro</strong
|
||||
> for individual creators, plus our new
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Teams</strong
|
||||
> plan for multiple users under shared billing.
|
||||
</p>
|
||||
|
||||
<PlansPricingCta
|
||||
href={routes.cloudPricing}
|
||||
label="See plans and pricing →"
|
||||
/>
|
||||
|
||||
<p class="text-primary-warm-gray mt-8 text-sm">
|
||||
Still think you have an enterprise need?<br /> We're happy to assist. Email: <a
|
||||
href="mailto:gtm-team@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>gtm-team@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
Need help with something else? Email: <a
|
||||
href="mailto:support@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>support@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
54
apps/website/src/pages/zh-CN/booking-confirmation.astro
Normal file
54
apps/website/src/pages/zh-CN/booking-confirmation.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: '学习中心',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: '工作流模板',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: '客户案例',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: '文档',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="预约成功 - Comfy"
|
||||
description="您的会议已预约成功。请查收邮件中的日历邀请和会议链接。"
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="预约成功。" class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>请查收邮件中的日历邀请和会议链接!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
等待期间的资源
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
57
apps/website/src/pages/zh-CN/individual-submission.astro
Normal file
57
apps/website/src/pages/zh-CN/individual-submission.astro
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PlansPricingCta from '../../components/individual-submission/PlansPricingCta.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
import { getRoutes } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="感谢您的联系 - Comfy"
|
||||
description="感谢您的联系。根据您提供的信息,我们的自助服务套餐之一可能更适合您。"
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="感谢您的联系。" class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>
|
||||
根据您提供的信息,我们的自助服务套餐之一可能更适合您。面向个人创作者的
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Standard</strong
|
||||
>、
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Creator</strong
|
||||
>
|
||||
和
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Pro</strong
|
||||
>,以及我们全新的
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Teams</strong
|
||||
> 套餐,可让多位用户共享账单。
|
||||
</p>
|
||||
|
||||
<PlansPricingCta href={routes.cloudPricing} label="查看套餐与价格 →" />
|
||||
|
||||
<p class="text-primary-warm-gray mt-8 text-sm">
|
||||
仍然认为您有企业级需求?我们很乐意为您提供帮助。邮箱:<a
|
||||
href="mailto:gtm-team@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>gtm-team@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
需要其他方面的帮助?邮箱:<a
|
||||
href="mailto:support@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>support@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -54,6 +54,11 @@
|
||||
"source": "/press",
|
||||
"destination": "/about",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/login",
|
||||
"destination": "https://cloud.comfy.org/login",
|
||||
"permanent": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, { name: string }>
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
130
scripts/nodeDefLocaleSerializer.test.ts
Normal file
130
scripts/nodeDefLocaleSerializer.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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'])
|
||||
})
|
||||
})
|
||||
127
scripts/nodeDefLocaleSerializer.ts
Normal file
127
scripts/nodeDefLocaleSerializer.ts
Normal file
@@ -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<string, LocalizableInput>
|
||||
name: string
|
||||
outputs: LocalizableOutput[]
|
||||
description?: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export type WidgetLabels = Record<
|
||||
string,
|
||||
Record<string, { name: string | undefined }>
|
||||
>
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user