Compare commits

...

5 Commits

Author SHA1 Message Date
Matt Miller
ee2a2b1b07 chore: add AGENTS.md code owner
Assign @christian-byrne as owner of **/AGENTS.md so agent instruction changes get a review gate. Other LLM-instruction paths stay intentionally ownerless.
2026-07-16 16:39:34 -07:00
claude[bot]
a154e6a311 Redirect comfy.org/login to cloud.comfy.org/login (#13727)
<!-- ccr-slack-attribution -->
_Requested by **nav** · [Slack
thread](https://comfy-organization.slack.com/archives/C0B61J7QCJW/p1784217313626029?thread_ts=1784217313.626029&cid=C0B61J7QCJW)_

## Summary

Redirect comfy.org/login to cloud.comfy.org/login.

## Changes

- **What**: Adds a `/login` entry to the `redirects` array in
`apps/website/vercel.json`, matching the existing `/blog` ->
`https://blog.comfy.org` cross-origin redirect pattern.

## Review Focus

- Before: `comfy.org/login` had no redirect (404/no route).
- After: `comfy.org/login` returns a 302 (`permanent: false`) to
`https://cloud.comfy.org/login`.
- How: Astro's native redirects don't support cross-origin destinations,
so this uses Vercel's `redirects` config in `apps/website/vercel.json`,
consistent with the existing `/blog` entry.

## Screenshots (if applicable)

N/A

---
_Generated by [Claude
Code](https://claude.ai/code/session_01UZGZ27NenZf5dzdvNcTFev)_

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-16 22:20:02 +00:00
steven-comfy
46fec1d47d feat: add hidden /booking-confirmation page for meeting-booking confirmations (#13730)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `/booking-confirmation`, a hidden `noindex` page intended as the
post-submit redirect target after a visitor books a meeting with the GTM
team. The page confirms the booking, tells the visitor to check their
email for the calendar invite, and offers a small list of "while you
wait" resources.

## Design

Mirrors the `/individual-submission` template landed in #13697 —
`BaseLayout` + `HeroSection` — so typography and spacing feel native to
the marketing site. The "Resources while you wait" heading uses the
yellow-italic accent used elsewhere on the site; each resource is a
centered `label — url` row.

Resource links source their URLs from `config/routes.ts` where possible
(`routes.learning`, `routes.customers`, `externalLinks.workflows`,
`externalLinks.docs`) so the page stays in sync if those paths ever
move.

## Hidden-page mechanics

Follows the pattern already used for `/individual-submission`,
`/terms-of-service`, and `/payment/*`:

- `noindex` meta tag on the page (both `en` and `zh-CN`)
- Both `/booking-confirmation` and `/zh-CN/booking-confirmation` added
to `SITEMAP_EXCLUDED_PATHNAMES` in `astro.config.ts` via
`LOCALE_PREFIXES`, so neither URL is emitted in the sitemap
- Not linked from `routes.ts`, `HeaderMain`, or `SiteFooter`
- English + Simplified Chinese variants both exist as full pages
(matching the existing `/individual-submission` treatment)

## Next step (not in this PR)

Someone with HubSpot / Chili Piper access needs to configure the
meeting-booking flow to redirect visitors to
`https://comfy.org/booking-confirmation` after they schedule.

## Verification

- `pnpm typecheck` → 0 errors, 0 warnings (10 pre-existing hints
unrelated to this change)
- `pnpm build` (in `apps/website`) → 504 pages built;
`/booking-confirmation/` and `/zh-CN/booking-confirmation/` emitted;
neither appears in `dist/sitemap-0.xml`
- `oxlint` and `oxfmt` clean on changed files (also enforced by
lint-staged pre-commit)
- Playwright preview at 1280×900 (English + zh-CN) and 390×844 (English
mobile) — hero, body copy, section header, and all 4 resource rows
render as expected; no horizontal overflow on mobile; only console noise
was transient Vite dep-optimizer 504s unrelated to this change
- `/review` (oracle) flagged one issue on the initial revision: the
zh-CN page was `noindex` but its URL still appeared in the sitemap.
Fixed by extending the exclusion set across both `LOCALE_PREFIXES`; the
follow-up `/review` returned 0 findings.

## Screenshots

## Screenshots

![Desktop 1280x900 view of /booking-confirmation: centered hero 'You're
booked.', body 'Check your email for the calendar invite and meeting
link!', yellow-italic 'Resources while you wait' heading, and four
centered rows showing Learning Center/Workflow templates/Customer
stories/Docs with their comfy.org
URLs.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/dc1451fed0bffdcc7c874fed8136c9b5f9b791d43773363fd4081bf285d8433e/pr-images/1784235033519-7d23611a-06c4-4a0b-9a2d-1bd641cc4141.png)

![Mobile 390x844 view of /booking-confirmation: same layout stacks
cleanly with no horizontal overflow; resource rows wrap onto two
centered lines
each.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/dc1451fed0bffdcc7c874fed8136c9b5f9b791d43773363fd4081bf285d8433e/pr-images/1784235033915-819c8536-6bb6-4172-af7a-efd28e6df53e.png)

![Desktop 1280x900 view of /zh-CN/booking-confirmation: Chinese
translation with hero '预约成功。', '等待期间的资源' heading, and four resource rows
in Simplified
Chinese.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/dc1451fed0bffdcc7c874fed8136c9b5f9b791d43773363fd4081bf285d8433e/pr-images/1784235034319-32b66ebb-50ae-47fb-ac64-be27b7e5a278.png)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Michael B <michael@imick.io>
2026-07-16 22:11:14 +00:00
Alexander Brown
1052658a02 refactor: isolate node locale serialization (#13643)
## Summary

Move node-definition locale serialization into a focused, testable
module while preserving generated locale behavior.

## Changes

- **What**: Extract locale key normalization and Vue I18n escaping from
the Playwright collector into `nodeDefLocaleSerializer.ts`.
- **What**: Keep input and output tooltips raw while escaping fields
compiled by Vue I18n.
- **What**: Replace broad utility tests with behavioral serializer
coverage for escaping, output shapes, ordering, and missing values.

## Review Focus

Check parity with the generator introduced in #13631, especially raw
tooltips, runtime widget labels, known data-type output elision, and
normalized keys.

Validation: `pnpm typecheck`, focused Vitest, oxlint, ESLint, and oxfmt.
2026-07-16 22:08:01 +00:00
steven-comfy
f524683f3c feat: add hidden /individual-submission page for sales-form redirects (#13697)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `/individual-submission`, a hidden `noindex` page intended as the
post-submit redirect target for the HubSpot sales form when the visitor
selects the "individual" option. The page thanks the visitor, points
them at the self-serve plans on `/cloud/pricing`, and offers a
`mailto:gtm-team@comfy.org` fallback for enterprise inquiries.

## Design

Mirrors the `terms-of-service.astro` template — `BaseLayout` +
`HeroSection` — so typography and spacing feel native to the marketing
site. Plan names (**Standard**, **Creator**, **Pro**, **Teams**) are
highlighted with the yellow-italic accent used elsewhere on the site.

## Hidden-page mechanics

Follows the pattern already used for `/terms-of-service` and
`/payment/*`:

- `noindex` meta tag on the page
- Path added to `SITEMAP_EXCLUDED_PATHNAMES` in `astro.config.ts`
- English-only, with a `/zh-CN/individual-submission` →
`/individual-submission` redirect (matches `termsOfService` handling)
- Not linked from `routes.ts`, `HeaderMain`, or `SiteFooter`

## Next step (not in this PR)

Someone with HubSpot access needs to configure the form (portal
`244637579`, form `94e05eab-1373-47f7-ab5e-d84f9e6aa262`) to redirect to
`https://comfy.org/individual-submission` when the "individual" option
is selected. Happy to add a JS-side fallback in a follow-up PR if
HubSpot's conditional redirects aren't a fit.

## Verification

- `pnpm typecheck` → 0 errors (10 pre-existing hints unrelated to this
change)
- `pnpm build` → 499 pages built; `/individual-submission/` and
`/zh-CN/individual-submission/` (redirect stub) emitted; sitemap
excludes both
- Playwright preview at 1280×900 and 390×844 — hero title, prose,
plan-name highlighting, CTA button, and mailto link all render as
expected; no console errors; `whitespace-nowrap` keeps the email address
on one line on mobile
- `/review` (oracle) on the previous revision: 0 findings; only change
since is the URL rename
- `/zh-CN/individual-submission` redirect verified in preview

## Screenshots

![Desktop view of /individual-submission: centered hero title 'Thanks
for reaching out.', body copy with Standard/Creator/Pro/Teams in yellow
italic, yellow 'See plans and pricing' CTA button, and a smaller mailto
fallback
line.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4ce277dd60743a347e517f79e6707eef6b43ec5fb8f54a92ea8c52d3858fb258/pr-images/1784158692177-e7fe7ed9-8844-4625-9823-1dd719173c73.png)

![Mobile 390px view of /individual-submission: same layout stacks
cleanly, gtm-team@comfy.org email stays on one line, no horizontal
overflow.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4ce277dd60743a347e517f79e6707eef6b43ec5fb8f54a92ea8c52d3858fb258/pr-images/1784158692523-e1af7deb-1379-4280-9bcc-0c602bb12aff.png)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: imick-io <michael@concreo.io>
Co-authored-by: Michael B <michael@imick.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:49:30 +00:00
16 changed files with 555 additions and 249 deletions

View File

@@ -60,5 +60,7 @@
.claude/
.cursor/
.cursorrules
**/AGENTS.md
**/CLAUDE.md
# Agent instructions
**/AGENTS.md @christian-byrne

View File

@@ -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(/\/$/, '')

View File

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

View File

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

View File

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

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

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

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

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

View File

@@ -54,6 +54,11 @@
"source": "/press",
"destination": "/about",
"permanent": true
},
{
"source": "/login",
"destination": "https://cloud.comfy.org/login",
"permanent": false
}
]
}

View File

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

View File

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

View File

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

View 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'])
})
})

View 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 }
}

View File

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