Compare commits

..

1 Commits

Author SHA1 Message Date
Hunter Senft-Grupp
97903f0f5a docs(adr): add 0011 derived credential lifecycle for cloud auth
Proposed ADR capturing the unifying invariants (single source of truth,
valid-on-read, single-flight, fail-closed, bounded reactive retry) behind the
cloud auth fixes in #13511. Split out from that PR for separate discussion.
2026-07-09 02:08:43 +00:00
445 changed files with 8949 additions and 33507 deletions

View File

@@ -41,9 +41,6 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Send local dev PostHog events to the staging project (cloud mode only).
# VITE_POSTHOG_PROJECT_TOKEN=
# Override staging comfy-api / comfy-platform base URLs.
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org

View File

@@ -40,6 +40,3 @@ jobs:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: pnpm --filter @comfyorg/website build
- name: Validate JSON-LD structured data
run: pnpm --filter @comfyorg/website validate:jsonld

View File

@@ -38,11 +38,9 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
# otherwise fall back to the raw git name.
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"

View File

@@ -278,49 +278,32 @@ jobs:
continue
fi
# Create backport branch. A failure here (e.g. dirty state left
# by a prior target) must not abort the loop and skip remaining
# targets, so fall back to a clean checkout and record the error.
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
git checkout main || git checkout -f main
echo "::endgroup::"
continue
fi
# Create backport branch
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
# Try cherry-pick
if git cherry-pick "${MERGE_COMMIT}"; then
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
git push --force-with-lease origin "${BACKPORT_BRANCH}"
else
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
git push origin "${BACKPORT_BRANCH}"
fi
# A push failure for one target must not abort the loop and
# prevent remaining targets from being attempted.
if "${PUSH_CMD[@]}"; then
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
else
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
fi
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
# Return to main (keep the branch, we need it for PR)
git checkout main || git checkout -f main
git checkout main
else
# Get conflict info
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
git cherry-pick --abort || true
git cherry-pick --abort
echo "::error::Cherry-pick failed due to conflicts"
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
# Clean up the failed branch
git checkout main || git checkout -f main
git branch -D "${BACKPORT_BRANCH}" || true
git checkout main
git branch -D "${BACKPORT_BRANCH}"
fi
echo "::endgroup::"
@@ -401,10 +384,6 @@ jobs:
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
The auto-backport could not be completed automatically. Please backport
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
open a PR to `${target}`.
<details>
<summary>📄 Conflicting files</summary>
@@ -437,37 +416,19 @@ jobs:
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
fi
# Post a comment without letting a single failed `gh pr comment` (e.g.
# a locked issue, as happened for PR #13359, or a transient API error)
# abort the step under `set -e` and swallow the remaining failures.
post_comment() {
local body="$1"
local context="$2"
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
fi
}
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r target reason conflicts <<< "${failure}"
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
if [ "${reason}" = "branch-missing" ]; then
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
elif [ "${reason}" = "already-exists" ]; then
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
elif [ "${reason}" = "branch-create-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
elif [ "${reason}" = "push-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
elif [ "${reason}" = "conflicts" ]; then
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
@@ -483,10 +444,10 @@ jobs:
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
fi
done

View File

@@ -76,14 +76,10 @@ test.describe('Affiliates landing — desktop interactions', () => {
return match?.textContent ?? null
})
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
'@type': string
mainEntity?: unknown[]
}[]
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
const parsed = JSON.parse(faqJsonLd!)
expect(parsed['@type']).toBe('FAQPage')
expect(Array.isArray(parsed.mainEntity)).toBe(true)
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
})
test('Apply Now CTA opens the application form in a new tab', async ({

View File

@@ -8,7 +8,7 @@ test.describe('Careers page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers - Comfy')
await expect(page).toHaveTitle('Careers Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
@@ -72,7 +72,7 @@ test.describe('Careers page role links', () => {
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')
await expect(page).toHaveTitle('招聘 - Comfy')
await expect(page).toHaveTitle('招聘 Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()

View File

@@ -9,7 +9,7 @@ test.describe('Cloud nodes page @smoke', () => {
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Custom-node packs on Comfy Cloud - supported by default'
'Custom-node packs on Comfy Cloud supported by default'
)
})

View File

@@ -8,7 +8,7 @@ test.describe('Cloud page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Cloud - AI in the Cloud')
await expect(page).toHaveTitle('Comfy Cloud AI in the Cloud')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {

View File

@@ -16,7 +16,7 @@ test.describe('Download page @smoke', () => {
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Download Comfy Desktop - Run AI on Your Hardware'
'Download Comfy Desktop Run AI on Your Hardware'
)
})

View File

@@ -17,7 +17,7 @@ test.describe('Homepage @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy - Professional Control of Visual AI')
await expect(page).toHaveTitle('Comfy Professional Control of Visual AI')
})
test('HeroSection heading is visible', async ({ page }) => {

View File

@@ -1,158 +0,0 @@
import { expect } from '@playwright/test'
import { learningTutorials } from '../src/data/learningTutorials'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
test.describe('Learning page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/learning')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Learning - Comfy')
})
test('hero headline references ComfyUI', async ({ page }) => {
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
await expect(heading).toContainText('ComfyUI')
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
})
test('featured workflow section shows title and author', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.featured.title', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByText(t('learning.featured.author', 'en'))
).toBeVisible()
})
test('renders every tutorial from the data source', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'en'),
level: 2
})
).toBeVisible()
for (const tutorial of learningTutorials) {
await expect(
page.getByRole('button', {
name: tutorialButtonName(tutorial.title.en, 'en')
})
).toBeVisible()
}
})
test('tutorials with a workflow link expose an external Try Workflow link', async ({
page
}) => {
const linkedTutorials = learningTutorials.filter(
(tutorial) => tutorial.href
)
const workflowLinks = page.getByRole('link', {
name: t('cta.tryWorkflow', 'en')
})
const hrefs = await workflowLinks.evaluateAll((links) =>
links.map((link) => link.getAttribute('href'))
)
for (const tutorial of linkedTutorials) {
expect(hrefs).toContain(tutorial.href)
}
})
test('call to action links to contact sales', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.cta.heading', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
).toHaveAttribute('href', '/contact')
})
})
test.describe('Learning tutorial dialog', () => {
test('opens a tutorial video and dismisses via the close button', async ({
page
}) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
// TutorialsSection is hydrated via `client:visible`; retry the click until
// Vue responds by opening the dialog.
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await expect(
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
).toBeVisible()
await dialog
.getByRole('button', { name: t('gallery.detail.close', 'en') })
.click()
await expect(dialog).toBeHidden()
})
test('dismisses the dialog with the Escape key', async ({ page }) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
})
})
test.describe('Learning page (zh-CN) @smoke', () => {
test('renders localized title, headings, and tutorials', async ({ page }) => {
await page.goto('/zh-CN/learning')
await expect(page).toHaveTitle('学习 - Comfy')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
/[一-鿿]/
)
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'zh-CN'),
level: 2
})
).toBeVisible()
const [firstTutorial] = learningTutorials
await expect(
page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
})
).toBeVisible()
})
})

View File

@@ -31,26 +31,6 @@ test.describe('Desktop navigation @smoke', () => {
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of ['Products', 'Community']) {
await expect(
desktopLinks
.getByRole('button', { name: label })
.getByText('NEW', { exact: true })
).toBeVisible()
}
await expect(
desktopLinks.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
desktopLinks.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('CTA buttons are visible', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
@@ -137,27 +117,6 @@ test.describe('Mobile menu @mobile', () => {
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.getByRole('dialog')
for (const label of ['Products', 'Community']) {
await expect(
menu.getByRole('button', { name: label }).getByText('NEW', {
exact: true
})
).toBeVisible()
}
await expect(
menu.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
menu.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('clicking section with subitems drills down and back works', async ({
page
}) => {

View File

@@ -22,7 +22,7 @@ test.describe('Payment success page @smoke', () => {
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Successful - Comfy')
await expect(page).toHaveTitle('Payment Successful Comfy')
await expectNoIndex(page)
})
@@ -54,7 +54,7 @@ test.describe('Payment failed page @smoke', () => {
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Failed - Comfy')
await expect(page).toHaveTitle('Payment Failed Comfy')
await expectNoIndex(page)
})
@@ -84,7 +84,7 @@ test.describe('Payment failed page @smoke', () => {
test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN success page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/success')
await expect(page).toHaveTitle('支付成功 - Comfy')
await expect(page).toHaveTitle('支付成功 Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '支付成功', level: 1 })
@@ -99,7 +99,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN failed page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/failed')
await expect(page).toHaveTitle('支付失败 - Comfy')
await expect(page).toHaveTitle('支付失败 Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '无法完成支付', level: 1 })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -17,8 +17,7 @@
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
"generate:models": "tsx ./scripts/generate-models.ts",
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
"generate:models": "tsx ./scripts/generate-models.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",

View File

@@ -1,129 +0,0 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { collectGraphIds } from '../src/utils/jsonLd'
const DIST_DIR = join(process.cwd(), 'dist')
const JSON_LD_BLOCK =
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
interface Violation {
file: string
message: string
}
function htmlFiles(dir: string): string[] {
return readdirSync(dir, { recursive: true })
.map(String)
.filter((entry) => entry.endsWith('.html'))
.map((entry) => join(dir, entry))
}
function typesOf(node: Record<string, unknown>): string[] {
const type = node['@type']
if (typeof type === 'string') return [type]
if (Array.isArray(type)) {
return type.filter((t): t is string => typeof t === 'string')
}
return []
}
function hasValidPrice(node: Record<string, unknown>): boolean {
const price = node.price
const priceStr = price == null ? '' : String(price).trim()
return priceStr !== '' && !Number.isNaN(Number(priceStr))
}
function checkHonesty(
value: unknown,
file: string,
violations: Violation[]
): void {
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(walk)
return
}
if (!node || typeof node !== 'object') return
const record = node as Record<string, unknown>
const types = typesOf(record)
if (types.includes('Review') || types.includes('AggregateRating')) {
violations.push({
file,
message: `dishonest node type ${types.join('/')}`
})
}
if ('aggregateRating' in record || 'review' in record) {
violations.push({
file,
message: 'node carries a review/aggregateRating'
})
}
if (
types.includes('Offer') &&
(!hasValidPrice(record) || !record.priceCurrency)
) {
violations.push({
file,
message: 'Offer missing priceCurrency or a concrete price'
})
}
Object.values(record).forEach(walk)
}
walk(value)
}
function validateFile(file: string): Violation[] {
const html = readFileSync(file, 'utf8')
const violations: Violation[] = []
const definedIds = new Set<string>()
const referencedIds: string[] = []
for (const match of html.matchAll(JSON_LD_BLOCK)) {
let parsed: unknown
try {
parsed = JSON.parse(match[1])
} catch (error) {
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
continue
}
checkHonesty(parsed, file, violations)
const { defined, references } = collectGraphIds(parsed)
defined.forEach((id) => definedIds.add(id))
referencedIds.push(...references)
}
for (const id of referencedIds) {
if (!definedIds.has(id)) {
violations.push({ file, message: `unresolved @id reference: ${id}` })
}
}
return violations
}
function main(): void {
const files = htmlFiles(DIST_DIR)
if (files.length === 0) {
console.error(
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
)
process.exit(1)
}
const violations = files.flatMap(validateFile)
if (violations.length > 0) {
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
for (const { file, message } of violations) {
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
}
process.exit(1)
}
process.stdout.write(
`JSON-LD validation passed across ${files.length} page(s).\n`
)
}
main()

View File

@@ -1,41 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import HeroBackdrop01 from './HeroBackdrop01.vue'
const sampleImage =
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
const meta: Meta<typeof HeroBackdrop01> = {
title: 'Website/Blocks/HeroBackdrop01',
component: HeroBackdrop01,
tags: ['autodocs'],
args: {
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
title: 'Build anything\nwith ComfyUI',
subtitle:
'A powerful, modular visual interface for building and running AI workflows.'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithBadge: Story = {
args: {
badgeText: 'New'
}
}
export const WithFootnote: Story = {
args: {
footnote: 'Available on Windows, macOS, and Linux.'
}
}
export const NoBackdrop: Story = {
args: {
backdrop: undefined
}
}

View File

@@ -1,193 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
type Backdrop =
| { type: 'image'; src: string; alt?: string }
| { type: 'video'; src: string; poster?: string; alt?: string }
const {
backdrop,
mobileBackdrop,
badgeText,
badgeLogoSrc,
badgeLogoAlt,
title,
subtitle,
footnote,
class: className
} = defineProps<{
backdrop?: Backdrop
mobileBackdrop?: Backdrop
badgeText?: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
subtitle?: string
footnote?: string
class?: HTMLAttributes['class']
}>()
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
const reduceMotion = computed(() => prefersReducedMotion())
// Removing the reactive `autoplay` attribute only suppresses the *initial*
// play; it can't pause a video the browser has already started. That is
// exactly the SSR case: the server renders `autoplay` (it can't read the
// client's motion preference), the browser begins playback on parse, and the
// post-hydration attribute removal is too late. Pause on mount so
// reduced-motion users get the poster frame instead of a looping video.
const pauseIfReduced = (el: unknown) => {
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
}
// On mobile the backdrop is an in-flow rounded card above the content; on
// desktop it is the full-bleed background behind it. A single element serves
// both roles via responsive classes — mobileBackdrop only swaps the source.
const sharedBackdropClass =
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
// When both breakpoints use images, serve them from a single responsive <img>
// so the browser fetches only the source matching the viewport. Two
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
// does not stop the fetch), doubling the high-priority load on an
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
// way and fall back to breakpoint-toggled layers below.
const responsiveImage = computed(() => {
if (backdrop?.type !== 'image') return null
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
const base = mobileBackdrop ?? backdrop
return {
src: base.src,
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
// Larger-viewport source; omitted when one image serves both breakpoints.
desktopSrc: mobileBackdrop ? backdrop.src : undefined
}
})
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
const backdropLayers = computed(() => {
if (!backdrop) return []
if (mobileBackdrop) {
return [
{
backdrop: mobileBackdrop,
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
},
{
backdrop,
class: 'absolute inset-0 hidden size-full object-cover lg:block'
}
]
}
return [{ backdrop, class: sharedBackdropClass }]
})
const scrimShape = 'farthest-side at 50% 50%'
const scrimStyle = {
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
}
</script>
<template>
<section
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
>
<div class="relative overflow-hidden rounded-3xl">
<slot name="backdrop">
<picture v-if="responsiveImage" class="contents">
<source
v-if="responsiveImage.desktopSrc"
:srcset="responsiveImage.desktopSrc"
media="(min-width: 1024px)"
/>
<img
:src="responsiveImage.src"
:alt="responsiveImage.alt"
fetchpriority="high"
decoding="async"
:class="sharedBackdropClass"
/>
</picture>
<template v-else>
<template v-for="(layer, i) in backdropLayers" :key="i">
<video
v-if="layer.backdrop.type === 'video'"
:ref="pauseIfReduced"
:src="layer.backdrop.src"
:poster="layer.backdrop.poster"
:aria-label="layer.backdrop.alt"
:aria-hidden="layer.backdrop.alt ? undefined : true"
:autoplay="!reduceMotion"
loop
muted
playsinline
preload="metadata"
:class="layer.class"
/>
<img
v-else
:src="layer.backdrop.src"
:alt="layer.backdrop.alt ?? ''"
fetchpriority="high"
decoding="async"
:class="layer.class"
/>
</template>
</template>
</slot>
<div
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
>
<div class="relative w-full max-w-xl">
<div
aria-hidden="true"
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
:style="scrimStyle"
/>
<div class="relative">
<ProductHeroBadge
v-if="badgeText"
:text="badgeText"
:logo-src="badgeLogoSrc"
:logo-alt="badgeLogoAlt"
/>
<h1
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
>
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
>
{{ subtitle }}
</p>
<p
v-if="footnote"
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
>
{{ footnote }}
</p>
<slot />
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -16,7 +16,6 @@ import type { NavItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NavColumn from './NavColumn.vue'
import NavFeaturedCard from './NavFeaturedCard.vue'
import NewBadge from './NewBadge.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const mainNavigation = getMainNavigation(locale)
@@ -43,10 +42,7 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
<NavigationMenuTrigger
:active="isNavItemActive(navItem, currentPath)"
>
<span class="inline-flex items-center gap-1">
<span class="ppformula-text-center">{{ navItem.label }}</span>
<NewBadge v-if="navItem.badge" :locale="locale" size="xxs" />
</span>
{{ navItem.label }}
</NavigationMenuTrigger>
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
<ul class="flex w-max gap-16">

View File

@@ -8,7 +8,6 @@ import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import NavLinkContent from './NavLinkContent.vue'
import NewBadge from './NewBadge.vue'
import Sheet from '@/components/ui/sheet/Sheet.vue'
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
@@ -97,8 +96,7 @@ onUnmounted(() => {
:href="item.columns ? undefined : item.href"
@click="item.columns && (activeSection = item.label)"
>
<span class="ppformula-text-center">{{ item.label }}</span>
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
{{ item.label }}
<template #append>
<ChevronRight class="size-7" />
</template>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import { ArrowUpRight } from '@lucide/vue'
import type { NavColumnItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NewBadge from './NewBadge.vue'
import { t } from '../../../i18n/translations'
defineProps<{ item: NavColumnItem; locale: Locale }>()
</script>
@@ -11,7 +12,9 @@ defineProps<{ item: NavColumnItem; locale: Locale }>()
<template>
<span class="flex items-center gap-2">
<span class="ppformula-text-center">{{ item.label }}</span>
<NewBadge v-if="item.badge" :locale="locale" size="xs" />
<Badge v-if="item.badge" size="xs" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
<ArrowUpRight
v-if="item.external"
class="text-primary-comfy-yellow size-4"

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import type { BadgeVariants } from '@/components/ui/badge'
import type { Locale } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
defineProps<{ locale: Locale; size?: BadgeVariants['size'] }>()
</script>
<template>
<Badge :size="size" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
</template>

View File

@@ -1,12 +0,0 @@
---
import type { JsonLdGraph } from '../../utils/jsonLd'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
interface Props {
graph: JsonLdGraph
}
const { graph } = Astro.props
---
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { localizeHref } from '../../config/routes'
import { t } from '../../i18n/translations'
const {
@@ -16,7 +15,8 @@ const {
locale?: Locale
}>()
const nextHref = localizeHref(`/demos/${nextSlug}`, locale)
const localePrefix = locale === 'en' ? '' : `/${locale}`
const nextHref = `${localePrefix}/demos/${nextSlug}`
</script>
<template>

View File

@@ -6,15 +6,14 @@ export const badgeVariants = cva({
variants: {
size: {
md: 'px-4 py-1 text-xs',
xs: 'px-2 py-0.5 text-[9px]',
xxs: 'px-1.5 py-px text-[8px]'
xs: 'px-2 py-0.5 text-[9px]'
},
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
category: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
accent:
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
}
},
defaultVariants: {

View File

@@ -1,10 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { Check, Copy } from '@lucide/vue'
import { useClipboard } from '@vueuse/core'
import { computed } from 'vue'
// Interactive: the copy button is inert until its host island is hydrated.
// Render under a `client:*` directive (e.g. `client:visible`) when the page
// needs it to work.
@@ -14,8 +11,6 @@ const {
copiedLabel = 'Copied'
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
const multiline = computed(() => value.includes('\n'))
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
function handleCopy() {
@@ -25,32 +20,15 @@ function handleCopy() {
<template>
<div
:class="
cn(
'bg-transparency-white-t4 border-primary-warm-gray flex gap-2 rounded-xl border px-4 py-3',
multiline ? 'items-start' : 'items-center'
)
"
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
>
<span
:class="
cn(
'flex-1 font-mono text-xs text-primary-comfy-canvas',
multiline ? 'wrap-break-word whitespace-pre-line' : 'truncate'
)
"
>
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
{{ value }}
</span>
<button
type="button"
:aria-label="copied ? copiedLabel : copyLabel"
:class="
cn(
'text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas',
multiline && 'mt-0.5'
)
"
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
@click="handleCopy"
>
<component :is="copied ? Check : Copy" class="size-4" />

View File

@@ -1,31 +0,0 @@
import { describe, expect, it } from 'vitest'
import { isHrefActive } from './useCurrentPath'
describe('isHrefActive', () => {
it('matches the current page', () => {
expect(isHrefActive('/mcp', '/mcp')).toBe(true)
})
it('does not match other pages', () => {
expect(isHrefActive('/mcp', '/pricing')).toBe(false)
})
it('matches regardless of a trailing slash', () => {
expect(isHrefActive('/mcp', '/mcp/')).toBe(true)
})
it('ignores query and hash on the href', () => {
expect(isHrefActive('/mcp?ref=banner#setup', '/mcp')).toBe(true)
})
it('never matches an external href', () => {
expect(
isHrefActive('https://docs.comfy.org/agent-tools/cloud', '/mcp')
).toBe(false)
})
it('never matches an empty href', () => {
expect(isHrefActive('', '/mcp')).toBe(false)
})
})

View File

@@ -3,7 +3,6 @@ import type { Locale, TranslationKey } from '../i18n/translations'
import { t } from '../i18n/translations'
import { resolveRel } from '../utils/cta'
import { localizeHref } from './routes'
// The banner "CMS": a single typed config resolved through i18n at build time.
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
@@ -74,7 +73,7 @@ export function getBannerData(
: undefined,
link: link
? {
href: localizeHref(link.href, locale),
href: link.href,
title: t(link.titleKey, locale),
target,
rel: resolveRel({ target: target ?? '_self' }),

View File

@@ -1,53 +0,0 @@
import { t } from '../i18n/translations'
import type { Locale, TranslationKey } from '../i18n/translations'
import { externalLinks } from './routes'
interface PricingTier {
slug: string
labelKey: TranslationKey
priceKey: TranslationKey
}
const tiers: PricingTier[] = [
{
slug: 'standard',
labelKey: 'pricing.plan.standard.label',
priceKey: 'pricing.plan.standard.price'
},
{
slug: 'creator',
labelKey: 'pricing.plan.creator.label',
priceKey: 'pricing.plan.creator.price'
},
{
slug: 'pro',
labelKey: 'pricing.plan.pro.label',
priceKey: 'pricing.plan.pro.price'
}
]
export interface PricingOffer {
name: string
price: string
url: string
}
export function pricingOffers(locale: Locale): PricingOffer[] {
return tiers.flatMap((tier) => {
const display = t(tier.priceKey, locale).trim()
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
if (!match) {
console.warn(
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
)
return []
}
return [
{
name: t(tier.labelKey, locale),
price: match[1],
url: `${externalLinks.cloud}/cloud/subscribe?tier=${tier.slug}&cycle=monthly`
}
]
})
}

View File

@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest'
import { localizeHref } from './routes'
describe('localizeHref', () => {
it('prefixes an internal path for a non-default locale', () => {
expect(localizeHref('/mcp', 'zh-CN')).toBe('/zh-CN/mcp')
})
it('leaves the default locale unprefixed', () => {
expect(localizeHref('/mcp', 'en')).toBe('/mcp')
})
it('passes external URLs through unchanged', () => {
expect(
localizeHref('https://docs.comfy.org/agent-tools/cloud', 'zh-CN')
).toBe('https://docs.comfy.org/agent-tools/cloud')
})
it('never prefixes locale-invariant routes', () => {
expect(localizeHref('/terms-of-service', 'zh-CN')).toBe('/terms-of-service')
})
})

View File

@@ -47,26 +47,13 @@ const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
'enterpriseMsa'
])
const LOCALE_INVARIANT_PATHS = new Set<string>(
[...LOCALE_INVARIANT_ROUTE_KEYS].map((key) => baseRoutes[key])
)
/**
* Prefix an internal path with the locale (`/mcp` → `/zh-CN/mcp`). External
* URLs and locale-invariant routes pass through unchanged.
*/
export function localizeHref(href: string, locale: Locale = 'en'): string {
if (locale === 'en' || !href.startsWith('/')) return href
if (LOCALE_INVARIANT_PATHS.has(href)) return href
return `/${locale}${href}`
}
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
const prefix = `/${locale}`
return Object.fromEntries(
Object.entries(baseRoutes).map(([key, path]) => [
key,
localizeHref(path, locale)
Object.entries(baseRoutes).map(([k, v]) => [
k,
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
])
) as unknown as Routes
}
@@ -82,19 +69,15 @@ export const externalLinks = {
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
g2ComfyUi: 'https://www.g2.com/products/comfyui',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
linkedin: 'https://www.linkedin.com/company/comfyui',
mcpServer: 'https://cloud.comfy.org/mcp',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',
support: 'https://support.comfy.org/hc/en-us',
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
workflows: 'https://comfy.org/workflows',
x: 'https://x.com/ComfyUI',
youtube: 'https://www.youtube.com/@ComfyOrg'

View File

@@ -72,24 +72,6 @@ export const drops: readonly Drop[] = [
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'app-mode',
badge: NEW_BADGE,
@@ -130,6 +112,24 @@ export const drops: readonly Drop[] = [
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'community-workflows',
category: COMMUNITY,

View File

@@ -30,23 +30,15 @@ export type NavItem =
label: string
columns: NavColumn[]
featured?: NavFeatured
badge?: 'new'
href?: never
}
| {
label: string
href: string
badge?: 'new'
columns?: never
featured?: never
}
| { label: string; href: string; columns?: never; featured?: never }
export function getMainNavigation(locale: Locale): NavItem[] {
const routes = getRoutes(locale)
return [
{
label: t('nav.products', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
imageAlt: t('nav.featuredProductsAlt', locale),
@@ -103,7 +95,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
{
label: t('nav.community', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
imageAlt: t('nav.featuredCommunityAlt', locale),

View File

@@ -823,7 +823,7 @@ const translations = {
'zh-CN': 'Comfy Cloud 支持的自定义节点包'
},
'cloudNodes.meta.title': {
en: 'Custom-node packs on Comfy Cloud - supported by default',
en: 'Custom-node packs on Comfy Cloud supported by default',
'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
},
'cloudNodes.meta.description': {
@@ -1845,8 +1845,8 @@ const translations = {
// MCP Meta
'mcp.meta.title': {
en: 'Comfy MCP - Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP - 让任何 AI 智能体驱动 ComfyUI'
en: 'Comfy MCP Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP 让任何 AI 智能体驱动 ComfyUI'
},
'mcp.meta.description': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
@@ -1872,10 +1872,6 @@ const translations = {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
},
'mcp.hero.installMcp': {
en: 'INSTALL MCP',
'zh-CN': '安装 MCP'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
@@ -1913,27 +1909,21 @@ const translations = {
},
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Ask your agent to install Comfy MCP',
'zh-CN': '让你的智能体安装 Comfy MCP'
},
'mcp.setup.step1.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
en: 'Copy the MCP URL',
'zh-CN': '复制 MCP URL'
},
'mcp.setup.step1.description': {
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
'zh-CN':
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
en: "Click the copy button below. You'll paste it into your client in the next step.",
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
},
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Or add it by hand',
'zh-CN': '或手动添加'
en: 'Add the connector',
'zh-CN': '添加连接器'
},
'mcp.setup.step2.description': {
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
'zh-CN':
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
},
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',
@@ -2190,13 +2180,6 @@ const translations = {
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
'breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'breadcrumb.about': { en: 'About Us', 'zh-CN': '关于我们' },
'breadcrumb.contact': { en: 'Contact', 'zh-CN': '联系我们' },
'breadcrumb.download': { en: 'Download', 'zh-CN': '下载' },
'breadcrumb.careers': { en: 'Careers', 'zh-CN': '招聘' },
'breadcrumb.pricing': { en: 'Pricing', 'zh-CN': '定价' },
'breadcrumb.supportedNodes': { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
@@ -3483,8 +3466,8 @@ const translations = {
},
'affiliate-terms.page.title': {
en: 'Affiliate Terms - Comfy',
'zh-CN': 'Affiliate Terms - Comfy'
en: 'Affiliate Terms Comfy',
'zh-CN': 'Affiliate Terms Comfy'
},
'affiliate-terms.page.description': {
en: 'Comfy.org Affiliate Program Terms and Conditions.',
@@ -3896,8 +3879,8 @@ const translations = {
'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.'
},
'enterprise-msa.page.title': {
en: 'Enterprise MSA - Comfy',
'zh-CN': 'Enterprise MSA - Comfy'
en: 'Enterprise MSA Comfy',
'zh-CN': 'Enterprise MSA Comfy'
},
'enterprise-msa.page.description': {
en: 'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.',
@@ -4068,6 +4051,7 @@ const translations = {
en: 'This page is being redesigned. Check back soon.',
'zh-CN': '此页面正在重新设计中,请稍后再来。'
},
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
'customers.story.whatsNext': {
@@ -4163,6 +4147,10 @@ const translations = {
en: "Run the world's leading AI models in ComfyUI",
'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
},
'models.breadcrumb.home': {
en: 'Home',
'zh-CN': '首页'
},
'models.breadcrumb.models': {
en: 'Supported Models',
'zh-CN': '支持的模型'
@@ -4361,8 +4349,8 @@ const translations = {
// Affiliate page (/affiliates) — head metadata
'affiliate.page.title': {
en: 'Comfy.org Affiliate Program - Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 - 成为合作伙伴'
en: 'Comfy.org Affiliate Program Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 成为合作伙伴'
},
'affiliate.page.description': {
en: 'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.',
@@ -4387,8 +4375,8 @@ const translations = {
// Launches page (/launches) — head metadata
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.page.title': {
en: 'ComfyUI Live Demo & Q&A - June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 - 6 月 29 日发布直播'
en: 'ComfyUI Live Demo & Q&A June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 6 月 29 日发布直播'
},
'launches.page.description': {
en: 'Join the ComfyUI livestream on June 29 for a hands-on product demo and live Q&A. See whats new across desktop, cloud, and community, and get your questions answered.',

View File

@@ -7,17 +7,14 @@ import SiteFooter from '../components/common/SiteFooter.vue'
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
import { bannerConfig, getBannerData } from '../config/banner'
import { isHrefActive } from '../composables/useCurrentPath'
import {
BANNER_DISMISS_ATTR,
BANNER_STORAGE_KEY,
createBannerVersion,
evaluateBannerVisibility
} from '../utils/banner'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
import { buildPageGraph, pageContext } from '../utils/jsonLd'
import type { Crumb, JsonLdNode, WebPageType } from '../utils/jsonLd'
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
interface Props {
title: string
@@ -25,10 +22,6 @@ interface Props {
keywords?: string[]
ogImage?: string
noindex?: boolean
pageType?: WebPageType
breadcrumbs?: Crumb[]
mainEntityId?: string
extraJsonLd?: (JsonLdNode | null | undefined)[]
}
const {
@@ -37,54 +30,52 @@ const {
keywords,
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
pageType,
breadcrumbs,
mainEntityId,
extraJsonLd,
} = Astro.props
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const canonicalURL = new URL(url)
const ogImageURL = new URL(ogImage, Astro.site ?? 'https://comfy.org')
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
// Announcement banner — build-time visibility gate + content-hash version key.
// A promo never advertises the page you are already on, so the banner is
// suppressed when its CTA points at the current path.
const bannerData = getBannerData(bannerConfig, locale)
const bannerVisible =
evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
}) && !isHrefActive(bannerData.link?.href ?? '', Astro.url.pathname)
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
})
const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
const structuredData = noindex
? undefined
: buildPageGraph(
{ siteUrl, locale },
{
url,
name: title,
description,
imageUrl: ogImageURL.href,
type: pageType,
crumbs: breadcrumbs,
mainEntityId,
},
...(extraJsonLd ?? []),
)
const organizationJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org',
logo: 'https://comfy.org/icons/logomark.svg',
sameAs: [
'https://github.com/comfyanonymous/ComfyUI',
'https://discord.gg/comfyorg',
'https://x.com/comaboratory',
'https://reddit.com/r/comfyui',
'https://linkedin.com/company/comfyorg',
'https://instagram.com/comfyorg',
],
}
const websiteJsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Comfy',
url: 'https://comfy.org',
}
---
<!doctype html>
@@ -126,7 +117,10 @@ const structuredData = noindex
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Structured Data -->
{structuredData && <JsonLdGraph graph={structuredData} />}
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
<slot name="head" />
<slot name="head" />
<!-- Google Tag Manager -->
@@ -146,6 +140,7 @@ const structuredData = noindex
)}
<ClientRouter />
<slot name="head" />
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
{bannerVisible && (

View File

@@ -3,7 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
---
<BaseLayout
title="404 - Page Not Found - Comfy"
title="404 Page Not Found Comfy"
noindex>
<div
class="flex w-full flex-col items-center p-4 h-[calc(100dvh-12rem)]">

View File

@@ -5,25 +5,9 @@ import StorySection from '../components/about/StorySection.vue'
import OurValuesSection from '../components/about/OurValuesSection.vue'
import ValuesSection from '../components/about/ValuesSection.vue'
import CareersSection from '../components/about/CareersSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="About Us - Comfy"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.about', locale) },
]}
>
<BaseLayout title="About Us — Comfy">
<HeroSection client:load />
<StorySection />
<OurValuesSection />

View File

@@ -9,36 +9,34 @@ import HeroSection from '../../templates/affiliate/HeroSection.vue'
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
import { affiliateFaqs } from '../../data/affiliateFaq'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
const pageTitle = t('affiliate.page.title', 'en')
const pageDescription = t('affiliate.page.description', 'en')
const { locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const faqPage: JsonLdNode = {
const locale = 'en' as const
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: affiliateFaqs.map((faq) => ({
'@type': 'Question',
name: faq.question[locale],
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
})),
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer[locale]
}
}))
}
---
<BaseLayout
title={pageTitle}
description={pageDescription}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: pageTitle },
]}
extraJsonLd={[faqPage]}
title={t('affiliate.page.title', locale)}
description={t('affiliate.page.description', locale)}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<HeroSection />
<HowItWorksSection />

View File

@@ -7,13 +7,6 @@ import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
import FAQSection from '../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../utils/ashby'
import { reportAshbyOutcome } from '../utils/ashby.ci'
import { t } from '../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../utils/jsonLd'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
@@ -26,31 +19,11 @@ if (outcome.status === 'failed') {
}
const departments = outcome.snapshot.departments
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const roles = itemListNode(
url,
t('breadcrumb.careers', locale),
departments.flatMap((department) =>
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
),
)
---
<BaseLayout
title="Careers - Comfy"
title="Careers Comfy"
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.careers', locale) },
]}
extraJsonLd={[roles]}
>
<HeroSection />
<RolesSection departments={departments} client:visible />

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ComingSoon from '../components/common/ComingSoon.astro'
---
<BaseLayout title="Case Studies - Comfy">
<BaseLayout title="Case Studies Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud - AI in the Cloud"
title="Comfy Cloud AI in the Cloud"
description={t('cloud.hero.subtitle', 'en')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']}
>

View File

@@ -2,41 +2,9 @@
import BaseLayout from '../../layouts/BaseLayout.astro'
import PriceSection from '../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
import { pricingOffers } from '../../config/pricing'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
jsonLdId,
pageContext,
productNode,
} from '../../utils/jsonLd'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const productId = jsonLdId(url, 'product')
---
<BaseLayout
title="Pricing - Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.pricing', locale) },
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy Cloud',
url,
offers: pricingOffers(locale),
}),
]}
>
<BaseLayout title="Pricing — Comfy Cloud">
<PriceSection client:load />
<WhatsIncludedSection />
</BaseLayout>

View File

@@ -4,44 +4,39 @@ import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../i18n/translations'
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../utils/jsonLd'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
const packs = await loadPacksForBuild()
const title = t('cloudNodes.meta.title', 'en')
const description = t('cloudNodes.meta.description', 'en')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const packList = itemListNode(
url,
title,
packs.map((pack) => ({
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Custom-node packs supported on Comfy Cloud',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
name: pack.displayName,
url: absoluteUrl(Astro.site, `/cloud/supported-nodes/${pack.id}`),
})),
)
image: pack.bannerUrl || pack.iconUrl
}))
}
---
<BaseLayout
title={title}
description={description}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.supportedNodes', locale) },
]}
extraJsonLd={[packList]}
title={t('cloudNodes.meta.title', 'en')}
description={t('cloudNodes.meta.description', 'en')}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection client:visible />
<PackGridSection packs={packs} client:visible />
</BaseLayout>

View File

@@ -7,12 +7,7 @@ import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
@@ -34,45 +29,35 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: pack.displayName,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
description: pack.description || undefined,
url: pageUrl,
description,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
authorName: pack.publisher?.name,
isFree: true,
})
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
---
<BaseLayout
title={title}
description={metaDescription}
ogImage={pack.bannerUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{
name: t('breadcrumb.supportedNodes', locale),
url: absoluteUrl(Astro.site, '/cloud/supported-nodes'),
},
{ name: pack.displayName },
]}
extraJsonLd={[software]}
>
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<PackDetail pack={pack} />
</BaseLayout>

View File

@@ -2,25 +2,9 @@
import BaseLayout from '../layouts/BaseLayout.astro'
import FormSection from '../components/contact/FormSection.vue'
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Contact - Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.contact', locale) },
]}
>
<BaseLayout title="Contact — Comfy">
<FormSection client:load />
<SocialProofBarSection />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { loadStories } from '../utils/loadStories'
const stories = (await loadStories('en')).map(toCardProps)
---
<BaseLayout title="Customer Stories - Comfy">
<BaseLayout title="Customer Stories Comfy">
<HeroSection client:load />
<StorySection stories={stories} />
<FeedbackSection client:load />

View File

@@ -17,7 +17,7 @@ export async function getStaticPaths() {
const { entry, next } = Astro.props
---
<BaseLayout title={`${entry.data.title} - Comfy`}>
<BaseLayout title={`${entry.data.title} Comfy`}>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -7,13 +7,6 @@ import DemoTranscript from '../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
organizationId,
pageContext,
} from '../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
@@ -26,55 +19,94 @@ const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title)
const description = t(demo.description)
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const educationalLevel =
demo.difficulty === 'beginner'
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced'
const learningId = jsonLdId(url, 'learning')
const learningResource: JsonLdNode = {
'@type': 'LearningResource',
'@id': learningId,
name: title,
description,
url,
image: new URL(demo.ogImage, Astro.site).href,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel,
timeRequired: demo.durationIso,
: 'Advanced',
url: canonicalURL.href,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
isPartOf: { '@id': jsonLdId(url, 'webpage') },
author: { '@id': organizationId(siteUrl) },
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos'),
item: 'https://comfy.org/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
}
---
<BaseLayout
title={`${title} - Comfy`}
title={`${title} Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('demos.breadcrumb.demos', locale),
url: absoluteUrl(Astro.site, '/demos'),
},
{ name: title },
]}
extraJsonLd={[learningResource]}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="Demos - Comfy" description="Interactive demos and tutorials for ComfyUI.">
<BaseLayout title="Demos Comfy" description="Interactive demos and tutorials for ComfyUI.">
<ComingSoon />
</BaseLayout>

View File

@@ -8,29 +8,11 @@ import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
import FAQSection from '../components/product/local/FAQSection.vue'
import { t } from '../i18n/translations'
import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
pageContext,
} from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Download Comfy Desktop - Run AI on Your Hardware"
title="Download Comfy Desktop Run AI on Your Hardware"
description={t('download.hero.subtitle', 'en')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
>
<CloudBannerSection />

View File

@@ -5,7 +5,7 @@ import GallerySection from '../components/gallery/GallerySection.vue'
import ContactSection from '../components/gallery/ContactSection.vue'
---
<BaseLayout title="Gallery - Comfy">
<BaseLayout title="Gallery Comfy">
<HeroSection />
<GallerySection client:load />
<ContactSection />

View File

@@ -9,28 +9,11 @@ import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSect
import GetStartedSection from "../components/home/GetStartedSection.vue";
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
import { t } from "../i18n/translations";
import {
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from "../utils/jsonLd";
const { siteUrl } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
);
---
<BaseLayout
title="Comfy - Professional Control of Visual AI"
title="Comfy Professional Control of Visual AI"
description={t("hero.subtitle", "en")}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[
comfyUiApplicationNode(siteUrl),
comfyUiSourceCodeNode(siteUrl),
]}
keywords={[
"comfyui app",
"comfyui web app",

View File

@@ -12,7 +12,7 @@ import { externalLinks } from '../config/routes'
const routes = getRoutes('en')
---
<BaseLayout title="Learning - Comfy">
<BaseLayout title="Learning Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection client:visible />

View File

@@ -7,7 +7,7 @@ import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vu
---
<BaseLayout
title="Models - Comfy"
title="Models Comfy"
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
>
<ModelsHeroSection

View File

@@ -4,13 +4,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
import { models, getModelBySlug } from '../../../config/models'
import { t } from '../../../i18n/translations'
import type { JsonLdNode } from '../../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return models.map((model) => ({
@@ -26,6 +19,7 @@ if (model.canonicalSlug) {
}
const { displayName } = model
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
const dirDescriptions: Record<string, string> = {
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
@@ -46,31 +40,55 @@ const dirDescriptions: Record<string, string> = {
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
const softwareAppJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: displayName,
url,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Any',
})
const faqPage: JsonLdNode = {
url: canonicalURL.href,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('models.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('models.breadcrumb.models'),
item: 'https://comfy.org/p/supported-models'
},
{
'@type': 'ListItem',
position: 3,
name: displayName
}
]
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: [
{
'@type': 'Question',
name: `What is ${displayName}?`,
acceptedAnswer: { '@type': 'Answer', text: whatIsDescription },
acceptedAnswer: {
'@type': 'Answer',
text: whatIsDescription
}
},
{
'@type': 'Question',
@@ -79,44 +97,54 @@ const faqPage: JsonLdNode = {
'@type': 'Answer',
text: model.docsUrl
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`,
},
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
}
},
{
'@type': 'Question',
name: `How many ComfyUI workflows use ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
},
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
}
},
{
'@type': 'Question',
name: `Is ${displayName} free to use in ComfyUI?`,
acceptedAnswer: {
'@type': 'Answer',
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`,
},
},
],
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
}
}
]
}
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
---
<BaseLayout
title={`${pageTitle} - Comfy`}
title={`${pageTitle} Comfy`}
description={pageDescription}
ogImage={model.thumbnailUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('models.breadcrumb.models', locale),
url: absoluteUrl(Astro.site, '/p/supported-models'),
},
{ name: displayName },
]}
extraJsonLd={[software, faqPage]}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(softwareAppJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<ModelHeroSection
displayName={displayName}

View File

@@ -2,29 +2,10 @@
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { models } from '../../../config/models'
import { t } from '../../../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../../utils/jsonLd'
const title = t('models.index.title')
const subtitle = t('models.index.subtitle')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const modelList = itemListNode(
url,
title,
models.map((model) => ({
url: absoluteUrl(Astro.site, `/p/supported-models/${model.slug}`),
})),
)
const dirLabel: Record<string, string> = {
diffusion_models: 'Diffusion',
checkpoints: 'Checkpoint',
@@ -43,15 +24,8 @@ const dirLabel: Record<string, string> = {
---
<BaseLayout
title={`${title} - Comfy`}
title={`${title} Comfy`}
description={subtitle}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: title },
]}
extraJsonLd={[modelList]}
>
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
<header class="mb-12">

View File

@@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection.
---
<BaseLayout
title="Payment Failed - Comfy"
title="Payment Failed Comfy"
description="Your payment was not completed."
noindex
>

View File

@@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection.
---
<BaseLayout
title="Payment Successful - Comfy"
title="Payment Successful Comfy"
description="Your payment was processed successfully."
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../components/legal/HeroSection.vue'
---
<BaseLayout
title="Privacy Policy - Comfy"
title="Privacy Policy Comfy"
description="Comfy privacy policy. Learn how we collect, use, and protect your personal information."
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop Privacy Policy - Comfy"
title="Desktop Privacy Policy Comfy"
description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights."
>
<HeroSection title="Desktop Privacy Policy" />

View File

@@ -6,7 +6,7 @@ import { t } from '../i18n/translations'
---
<BaseLayout
title="Terms of Service - Comfy"
title="Terms of Service Comfy"
description="Terms of Service governing use of the Comfy Products, including Comfy Cloud, Comfy API, and Comfy Enterprise."
noindex
>

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ComingSoon from '../components/common/ComingSoon.astro'
---
<BaseLayout title="Videos - Comfy">
<BaseLayout title="Videos Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -5,29 +5,9 @@ import StorySection from '../../components/about/StorySection.vue'
import OurValuesSection from '../../components/about/OurValuesSection.vue'
import ValuesSection from '../../components/about/ValuesSection.vue'
import CareersSection from '../../components/about/CareersSection.vue'
import { t } from '../../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="关于我们 - Comfy"
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.about', locale) },
]}
>
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<HeroSection locale="zh-CN" client:load />
<StorySection locale="zh-CN" />
<OurValuesSection locale="zh-CN" />

View File

@@ -7,13 +7,6 @@ import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
import FAQSection from '../../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../../utils/ashby'
import { reportAshbyOutcome } from '../../utils/ashby.ci'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../utils/jsonLd'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
@@ -26,34 +19,11 @@ if (outcome.status === 'failed') {
}
const departments = outcome.snapshot.departments
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const roles = itemListNode(
url,
t('breadcrumb.careers', locale),
departments.flatMap((department) =>
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
),
)
---
<BaseLayout
title="招聘 - Comfy"
title="招聘 Comfy"
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.careers', locale) },
]}
extraJsonLd={[roles]}
>
<HeroSection locale="zh-CN" />
<RolesSection locale="zh-CN" departments={departments} client:visible />

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="案例研究 - Comfy">
<BaseLayout title="案例研究 Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { t } from '../../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud - 云端 AI"
title="Comfy Cloud 云端 AI"
description={t('cloud.hero.subtitle', 'zh-CN')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'ComfyUI 网页版', 'ComfyUI 云端', 'ComfyUI 应用', 'Comfy Cloud', '云端 ComfyUI']}
>

View File

@@ -2,44 +2,9 @@
import BaseLayout from '../../../layouts/BaseLayout.astro'
import PriceSection from '../../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../../components/pricing/WhatsIncludedSection.vue'
import { pricingOffers } from '../../../config/pricing'
import { t } from '../../../i18n/translations'
import {
absoluteUrl,
jsonLdId,
pageContext,
productNode,
} from '../../../utils/jsonLd'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const productId = jsonLdId(url, 'product')
---
<BaseLayout
title="定价 - Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{ name: t('breadcrumb.pricing', locale) },
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy Cloud',
url,
offers: pricingOffers(locale),
}),
]}
>
<BaseLayout title="定价 — Comfy Cloud">
<PriceSection locale="zh-CN" client:load />
<WhatsIncludedSection locale="zh-CN" />
</BaseLayout>

View File

@@ -4,47 +4,39 @@ import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../../utils/jsonLd'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
const packs = await loadPacksForBuild()
const title = t('cloudNodes.meta.title', 'zh-CN')
const description = t('cloudNodes.meta.description', 'zh-CN')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const packList = itemListNode(
url,
title,
packs.map((pack) => ({
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Comfy Cloud 支持的自定义节点包',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
name: pack.displayName,
url: absoluteUrl(Astro.site, `/zh-CN/cloud/supported-nodes/${pack.id}`),
})),
)
image: pack.bannerUrl || pack.iconUrl
}))
}
---
<BaseLayout
title={title}
description={description}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{ name: t('breadcrumb.supportedNodes', locale) },
]}
extraJsonLd={[packList]}
title={t('cloudNodes.meta.title', 'zh-CN')}
description={t('cloudNodes.meta.description', 'zh-CN')}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection locale="zh-CN" client:visible />
<PackGridSection locale="zh-CN" packs={packs} client:visible />
</BaseLayout>

View File

@@ -7,12 +7,7 @@ import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { t } from '../../../../i18n/translations'
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../../utils/jsonLd'
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
@@ -34,48 +29,35 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: pack.displayName,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
description: pack.description || undefined,
url: pageUrl,
description,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
authorName: pack.publisher?.name,
isFree: true,
})
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
---
<BaseLayout
title={title}
description={metaDescription}
ogImage={pack.bannerUrl}
mainEntityId={softwareId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{
name: t('breadcrumb.supportedNodes', locale),
url: absoluteUrl(Astro.site, '/zh-CN/cloud/supported-nodes'),
},
{ name: pack.displayName },
]}
extraJsonLd={[software]}
>
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<PackDetail pack={pack} locale="zh-CN" />
</BaseLayout>

View File

@@ -2,28 +2,9 @@
import BaseLayout from '../../layouts/BaseLayout.astro'
import FormSection from '../../components/contact/FormSection.vue'
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
import { t } from '../../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="联系我们 - Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.contact', locale) },
]}
>
<BaseLayout title="联系我们 — Comfy">
<FormSection locale="zh-CN" client:load />
<SocialProofBarSection />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { loadStories } from '../../utils/loadStories'
const stories = (await loadStories('zh-CN')).map(toCardProps)
---
<BaseLayout title="客户故事 - Comfy">
<BaseLayout title="客户故事 Comfy">
<HeroSection locale="zh-CN" client:load />
<StorySection stories={stories} locale="zh-CN" />
<FeedbackSection locale="zh-CN" client:load />

View File

@@ -17,7 +17,7 @@ export async function getStaticPaths() {
const { entry, next } = Astro.props
---
<BaseLayout title={`${entry.data.title} - Comfy`}>
<BaseLayout title={`${entry.data.title} Comfy`}>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -7,13 +7,6 @@ import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
import { t } from '../../../i18n/translations'
import type { JsonLdNode } from '../../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
organizationId,
pageContext,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
@@ -26,58 +19,94 @@ const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title, 'zh-CN')
const description = t(demo.description, 'zh-CN')
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const educationalLevel =
demo.difficulty === 'beginner'
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced'
const learningId = jsonLdId(url, 'learning')
const learningResource: JsonLdNode = {
'@type': 'LearningResource',
'@id': learningId,
name: title,
description,
url,
image: new URL(demo.ogImage, Astro.site).href,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel,
timeRequired: demo.durationIso,
: 'Advanced',
url: canonicalURL.href,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
isPartOf: { '@id': jsonLdId(url, 'webpage') },
author: { '@id': organizationId(siteUrl) },
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home', 'zh-CN'),
item: 'https://comfy.org/zh-CN'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos', 'zh-CN'),
item: 'https://comfy.org/zh-CN/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
}
---
<BaseLayout
title={`${title} - Comfy`}
title={`${title} Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{
name: t('demos.breadcrumb.demos', locale),
url: absoluteUrl(Astro.site, '/zh-CN/demos'),
},
{ name: title },
]}
extraJsonLd={[learningResource]}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>

View File

@@ -3,7 +3,7 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
---
<BaseLayout title="演示 - Comfy" description="ComfyUI 的互动演示和教程。">
<BaseLayout title="演示 Comfy" description="ComfyUI 的互动演示和教程。">
<section class="flex min-h-[60vh] items-center justify-center px-6">
<div class="text-center">
<h1 class="text-primary-comfy-canvas text-4xl font-light">

View File

@@ -8,32 +8,11 @@ import EcoSystemSection from '../../components/product/local/EcoSystemSection.vu
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
import FAQSection from '../../components/product/local/FAQSection.vue'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
pageContext,
} from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="下载 Comfy 桌面版 - 在您的硬件上运行 AI"
title="下载 Comfy 桌面版 在您的硬件上运行 AI"
description={t('download.hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>
<CloudBannerSection locale="zh-CN" />

View File

@@ -5,7 +5,7 @@ import GallerySection from '../../components/gallery/GallerySection.vue'
import ContactSection from '../../components/gallery/ContactSection.vue'
---
<BaseLayout title="作品集 - Comfy">
<BaseLayout title="作品集 Comfy">
<HeroSection locale="zh-CN" />
<GallerySection locale="zh-CN" client:load />
<ContactSection locale="zh-CN" />

View File

@@ -9,25 +9,11 @@ import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightS
import GetStartedSection from '../../components/home/GetStartedSection.vue'
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
import { t } from '../../i18n/translations'
import {
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from '../../utils/jsonLd'
const { siteUrl } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Comfy - 视觉 AI 的最强可控性"
title="Comfy 视觉 AI 的最强可控性"
description={t('hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
>
<HeroSection locale="zh-CN" client:load />

View File

@@ -11,7 +11,7 @@ import { learningEvents } from '../../data/events'
const routes = getRoutes('zh-CN')
---
<BaseLayout title="学习 - Comfy">
<BaseLayout title="学习 Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" client:visible />

View File

@@ -7,7 +7,7 @@ import ProductShowcaseSection from '../../components/home/ProductShowcaseSection
---
<BaseLayout
title="模型 - Comfy"
title="模型 Comfy"
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
>
<ModelsHeroSection

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout title="支付失败 - Comfy" description="您的支付未能完成。" noindex>
<BaseLayout title="支付失败 Comfy" description="您的支付未能完成。" noindex>
<PaymentStatusSection status="failed" locale="zh-CN" />
</BaseLayout>

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout title="支付成功 - Comfy" description="您的支付已成功完成。" noindex>
<BaseLayout title="支付成功 Comfy" description="您的支付已成功完成。" noindex>
<PaymentStatusSection status="success" locale="zh-CN" />
</BaseLayout>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="隐私政策 - Comfy"
title="隐私政策 Comfy"
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop 隐私政策 - Comfy"
title="Desktop 隐私政策 Comfy"
description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。"
>
<HeroSection title="Desktop 隐私政策" />

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="视频 - Comfy">
<BaseLayout title="视频 Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -61,12 +61,7 @@ const { drop, locale } = defineProps<{
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
/>
</div>
<Badge
v-if="drop.badge"
size="xs"
variant="accent"
class="absolute top-6 left-8"
>
<Badge v-if="drop.badge" variant="accent" class="absolute top-6 left-8">
{{ drop.badge[locale] }}
</Badge>
</CardContent>

View File

@@ -17,7 +17,7 @@ const ctas = mcpCtas(locale)
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"
:primary-cta="ctas.installMcp"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
>
<template #media>

View File

@@ -17,10 +17,7 @@ const cards: FeatureCard[] = [
description: t('mcp.setup.step1.description', locale),
action: {
type: 'code',
value: t('mcp.setup.step1.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
value: externalLinks.mcpServer
}
},
{
@@ -56,8 +53,6 @@ const cards: FeatureCard[] = [
<template>
<FeatureGrid01
id="setup"
class="scroll-mt-24 lg:scroll-mt-36"
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"

View File

@@ -9,25 +9,16 @@ export interface McpCta {
}
/**
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
* steps, or run a workflow in the cloud. The hero leads with install + docs;
* the "how it works" section pairs run-a-workflow with docs.
* The two calls-to-action shared by the MCP hero and "how it works" sections:
* view the docs, or run a workflow in the cloud.
*/
export function mcpCtas(locale: Locale): {
docs: McpCta
installMcp: McpCta
runWorkflow: McpCta
} {
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
return {
docs: {
label: t('mcp.hero.viewDocs', locale),
href: externalLinks.docsMcp,
target: '_blank'
},
installMcp: {
label: t('mcp.hero.installMcp', locale),
href: '#setup'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud

View File

@@ -1,212 +0,0 @@
import { describe, expect, it } from 'vitest'
import { externalLinks } from '../config/routes'
import { escapeJsonLd } from './escapeJsonLd'
import type { JsonLdGraph } from './jsonLd'
import {
absoluteUrl,
buildPageGraph,
collectGraphIds,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
itemListNode,
jsonLdId,
organizationId,
pageContext,
productNode,
softwareApplicationNode
} from './jsonLd'
const siteUrl = 'https://comfy.org'
const site = new URL('https://comfy.org/')
function typeNames(graph: JsonLdGraph): string[] {
return graph['@graph'].map((node) => node['@type'])
}
describe('absoluteUrl', () => {
it('resolves internal paths to their trailing-slash canonical form', () => {
expect(absoluteUrl(site, '/cloud')).toBe('https://comfy.org/cloud/')
expect(absoluteUrl(site, '/about/')).toBe('https://comfy.org/about/')
expect(absoluteUrl(site, '/')).toBe('https://comfy.org/')
})
})
describe('pageContext', () => {
it('derives siteUrl, locale and canonical url from the Astro globals', () => {
expect(pageContext(site, '/about/', undefined)).toEqual({
siteUrl,
locale: 'en',
url: 'https://comfy.org/about/'
})
expect(pageContext(site, '/zh-CN/', 'zh-CN').locale).toBe('zh-CN')
})
})
describe('itemListNode', () => {
it('counts items and omits per-item names when not supplied', () => {
const node = itemListNode('https://comfy.org/careers/', 'Careers', [
{ url: 'https://jobs.example/1' },
{ url: 'https://jobs.example/2', name: 'Designer' }
])
expect(node.numberOfItems).toBe(2)
const items = node.itemListElement as Record<string, unknown>[]
expect('name' in items[0]).toBe(false)
expect(items[1].name).toBe('Designer')
})
})
describe('softwareApplicationNode', () => {
it('claims Comfy Org as author and publisher only when first-party', () => {
const node = softwareApplicationNode({
siteUrl,
id: jsonLdId(siteUrl, 'software'),
name: 'ComfyUI',
url: siteUrl,
firstParty: true,
applicationCategory: 'MultimediaApplication',
isFree: true
})
const orgRef = { '@id': organizationId(siteUrl) }
expect(node.author).toEqual(orgRef)
expect(node.publisher).toEqual(orgRef)
expect(node.offers).toEqual({
'@type': 'Offer',
price: 0,
priceCurrency: 'USD',
seller: orgRef
})
})
it('does not name Comfy Org as seller on a third-party free offer', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
name: 'Foo Pack',
url: 'https://comfy.org/cloud/supported-nodes/foo/',
applicationCategory: 'DeveloperApplication',
isFree: true
})
expect((node.offers as Record<string, unknown>).seller).toBeUndefined()
})
it('credits a known third-party author without claiming to publish it', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
name: 'Foo Pack',
url: 'https://comfy.org/cloud/supported-nodes/foo/',
applicationCategory: 'DeveloperApplication',
authorName: 'Jane Dev'
})
expect(node.author).toEqual({ '@type': 'Person', name: 'Jane Dev' })
expect(node.publisher).toBeUndefined()
})
it('claims no author or publisher for third-party software with no author', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.author).toBeUndefined()
expect(node.publisher).toBeUndefined()
})
})
describe('sameAs encyclopedic references', () => {
it('links the organization to its Wikidata entity', () => {
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{ url: `${siteUrl}/`, name: 'Home' }
)
const org = graph['@graph'].find((node) => node['@type'] === 'Organization')
expect(org?.sameAs).toContain(externalLinks.wikidataComfyOrg)
})
it('links the ComfyUI application to its Wikidata, Wikipedia and G2 entities', () => {
const node = comfyUiApplicationNode(siteUrl)
expect(node.sameAs).toEqual([
externalLinks.wikidataComfyUi,
externalLinks.wikipediaComfyUi,
externalLinks.g2ComfyUi
])
})
it('omits sameAs for third-party software', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.sameAs).toBeUndefined()
})
})
describe('productNode', () => {
it('gives every offer a currency and price', () => {
const node = productNode({
siteUrl,
id: 'https://comfy.org/cloud/pricing/#product',
name: 'Comfy Cloud',
url: 'https://comfy.org/cloud/pricing/',
offers: [{ name: 'Standard', price: '20' }]
})
const offers = node.offers as Record<string, unknown>[]
expect(offers[0].price).toBe('20')
expect(offers[0].priceCurrency).toBe('USD')
expect(offers[0].seller).toEqual({ '@id': organizationId(siteUrl) })
})
})
describe('comfyUiSourceCodeNode', () => {
it('links the source code to the ComfyUI application via targetProduct', () => {
const node = comfyUiSourceCodeNode(siteUrl)
expect(node.targetProduct).toEqual({ '@id': comfyUiSoftwareId(siteUrl) })
})
})
describe('buildPageGraph', () => {
const url = 'https://comfy.org/cloud/pricing/'
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{
url,
name: 'Pricing',
type: 'CollectionPage',
mainEntityId: jsonLdId(url, 'itemlist'),
crumbs: [{ name: 'Home', url: `${siteUrl}/` }, { name: 'Pricing' }]
},
itemListNode(url, 'Plans', [{ url: `${siteUrl}/one/` }])
)
it('always includes the site-wide organization, website and page entity', () => {
expect(typeNames(graph)).toContain('Organization')
expect(typeNames(graph)).toContain('WebSite')
expect(typeNames(graph)).toContain('CollectionPage')
})
it('produces a graph where every @id reference resolves', () => {
const { defined, references } = collectGraphIds(graph)
for (const reference of references) {
expect(defined.has(reference)).toBe(true)
}
})
})
describe('escapeJsonLd on a built graph', () => {
it('neutralizes a </script> breakout in a page name', () => {
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{ url: `${siteUrl}/x/`, name: '</script><script>alert(1)</script>' }
)
const serialized = escapeJsonLd(graph)
expect(serialized).not.toContain('</script>')
expect(serialized).toContain('\\u003c')
})
})

View File

@@ -1,377 +0,0 @@
import { externalLinks } from '../config/routes'
import type { Locale } from '../i18n/translations'
export type JsonLdNode = Record<string, unknown> & { '@type': string }
export interface JsonLdGraph {
'@context': 'https://schema.org'
'@graph': JsonLdNode[]
}
export interface PageContext {
siteUrl: string
locale: Locale
}
export type WebPageType =
| 'WebPage'
| 'AboutPage'
| 'ContactPage'
| 'CollectionPage'
export interface Crumb {
name: string
url?: string
}
const sameAs = [
externalLinks.github,
externalLinks.x,
externalLinks.youtube,
externalLinks.discord,
externalLinks.instagram,
externalLinks.reddit,
externalLinks.linkedin,
// Wikidata entity for the organization, so the Knowledge Graph can resolve it.
externalLinks.wikidataComfyOrg
]
// Authoritative encyclopedic and review-platform references for the ComfyUI software entity.
const comfyUiSameAs = [
externalLinks.wikidataComfyUi,
externalLinks.wikipediaComfyUi,
externalLinks.g2ComfyUi
]
function siteUrlFrom(site: URL | undefined): string {
return (site?.href ?? 'https://comfy.org/').replace(/\/$/, '')
}
export function absoluteUrl(site: URL | undefined, path: string): string {
const resolved = new URL(path, site ?? 'https://comfy.org').href
return resolved.endsWith('/') ? resolved : `${resolved}/`
}
export function pageContext(
site: URL | undefined,
pathname: string,
currentLocale: string | undefined
): PageContext & { url: string } {
return {
siteUrl: siteUrlFrom(site),
locale: currentLocale === 'zh-CN' ? 'zh-CN' : 'en',
url: absoluteUrl(site, pathname)
}
}
export function jsonLdId(pageUrl: string, fragment: string): string {
return `${pageUrl}#${fragment}`
}
export function organizationId(siteUrl: string): string {
return `${siteUrl}/#organization`
}
function websiteId(siteUrl: string): string {
return `${siteUrl}/#website`
}
function buildGraph(...nodes: (JsonLdNode | null | undefined)[]): JsonLdGraph {
return {
'@context': 'https://schema.org',
'@graph': nodes.filter((node): node is JsonLdNode => Boolean(node))
}
}
function organizationNode(siteUrl: string): JsonLdNode {
return {
'@type': 'Organization',
'@id': organizationId(siteUrl),
name: 'Comfy Org',
url: siteUrl,
logo: {
'@type': 'ImageObject',
url: `${siteUrl}/web-app-manifest-512x512.png`,
width: 512,
height: 512
},
sameAs
}
}
function websiteNode(siteUrl: string): JsonLdNode {
return {
'@type': 'WebSite',
'@id': websiteId(siteUrl),
name: 'Comfy',
url: siteUrl,
publisher: { '@id': organizationId(siteUrl) }
}
}
function breadcrumbNode(pageUrl: string, crumbs: Crumb[]): JsonLdNode {
return {
'@type': 'BreadcrumbList',
'@id': jsonLdId(pageUrl, 'breadcrumb'),
itemListElement: crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
return isLast || !crumb.url
? { '@type': 'ListItem', position: index + 1, name: crumb.name }
: {
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: crumb.url
}
})
}
}
export function itemListNode(
pageUrl: string,
name: string,
items: { url: string; name?: string }[]
): JsonLdNode {
return {
'@type': 'ItemList',
'@id': jsonLdId(pageUrl, 'itemlist'),
name,
numberOfItems: items.length,
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
url: item.url,
...(item.name ? { name: item.name } : {})
}))
}
}
interface WebPageInput {
siteUrl: string
locale: Locale
url: string
name: string
description?: string
imageUrl?: string
crumbs?: Crumb[]
mainEntityId?: string
}
function webPageNode(input: WebPageInput, type: WebPageType): JsonLdNode {
const hasCrumbs = Boolean(input.crumbs && input.crumbs.length > 0)
return {
'@type': type,
'@id': jsonLdId(input.url, 'webpage'),
url: input.url,
name: input.name,
description: input.description,
isPartOf: { '@id': websiteId(input.siteUrl) },
primaryImageOfPage: input.imageUrl
? { '@type': 'ImageObject', url: input.imageUrl }
: undefined,
breadcrumb: hasCrumbs
? { '@id': jsonLdId(input.url, 'breadcrumb') }
: undefined,
mainEntity: input.mainEntityId ? { '@id': input.mainEntityId } : undefined,
inLanguage: input.locale
}
}
export interface SoftwareAppInput {
siteUrl: string
id: string
name: string
url: string
applicationCategory: string
firstParty?: boolean
applicationSubCategory?: string
description?: string
operatingSystem?: string
image?: string
softwareVersion?: string
license?: string
codeRepository?: string
authorName?: string
isFree?: boolean
sameAs?: string[]
}
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
const orgRef = { '@id': organizationId(input.siteUrl) }
const author = input.firstParty
? orgRef
: input.authorName
? { '@type': 'Person', name: input.authorName }
: undefined
return {
'@type': 'SoftwareApplication',
'@id': input.id,
name: input.name,
url: input.url,
applicationCategory: input.applicationCategory,
applicationSubCategory: input.applicationSubCategory,
description: input.description,
operatingSystem: input.operatingSystem,
image: input.image,
softwareVersion: input.softwareVersion,
license: input.license,
codeRepository: input.codeRepository,
author,
publisher: input.firstParty ? orgRef : undefined,
sameAs: input.sameAs,
offers: input.isFree
? {
'@type': 'Offer',
price: 0,
priceCurrency: 'USD',
seller: input.firstParty ? orgRef : undefined
}
: undefined
}
}
interface SourceCodeInput {
siteUrl: string
id: string
name: string
codeRepository: string
programmingLanguage?: string
targetProductId?: string
}
function softwareSourceCodeNode(input: SourceCodeInput): JsonLdNode {
return {
'@type': 'SoftwareSourceCode',
'@id': input.id,
name: input.name,
codeRepository: input.codeRepository,
programmingLanguage: input.programmingLanguage,
targetProduct: input.targetProductId
? { '@id': input.targetProductId }
: undefined,
author: { '@id': organizationId(input.siteUrl) }
}
}
export function comfyUiSoftwareId(siteUrl: string): string {
return `${siteUrl}/#software`
}
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
return softwareApplicationNode({
siteUrl,
id: comfyUiSoftwareId(siteUrl),
name: 'ComfyUI',
url: siteUrl,
firstParty: true,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Windows, macOS, Linux',
isFree: true,
sameAs: comfyUiSameAs
})
}
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
return softwareSourceCodeNode({
siteUrl,
id: `${siteUrl}/#sourcecode`,
name: 'ComfyUI',
codeRepository: externalLinks.github,
programmingLanguage: 'Python',
targetProductId: comfyUiSoftwareId(siteUrl)
})
}
interface OfferInput {
name: string
price: string | number
url?: string
}
export interface ProductInput {
siteUrl: string
id: string
name: string
url: string
offers: OfferInput[]
}
export function productNode(input: ProductInput): JsonLdNode {
return {
'@type': 'Product',
'@id': input.id,
name: input.name,
url: input.url,
brand: { '@id': organizationId(input.siteUrl) },
offers: input.offers.map((offer) => ({
'@type': 'Offer',
name: offer.name,
price: offer.price,
priceCurrency: 'USD',
url: offer.url,
seller: { '@id': organizationId(input.siteUrl) },
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: offer.price,
priceCurrency: 'USD',
unitText: 'MONTH'
}
}))
}
}
export interface PageGraphInput {
url: string
name: string
type?: WebPageType
description?: string
imageUrl?: string
crumbs?: Crumb[]
mainEntityId?: string
}
export function buildPageGraph(
ctx: PageContext,
page: PageGraphInput,
...extraNodes: (JsonLdNode | null | undefined)[]
): JsonLdGraph {
const { type = 'WebPage', ...rest } = page
const input: WebPageInput = {
...rest,
siteUrl: ctx.siteUrl,
locale: ctx.locale
}
const hasCrumbs = Boolean(page.crumbs && page.crumbs.length > 0)
return buildGraph(
organizationNode(ctx.siteUrl),
websiteNode(ctx.siteUrl),
webPageNode(input, type),
hasCrumbs ? breadcrumbNode(page.url, page.crumbs!) : undefined,
...extraNodes
)
}
export function collectGraphIds(value: unknown): {
defined: Set<string>
references: string[]
} {
const defined = new Set<string>()
const references: string[] = []
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(walk)
return
}
if (node && typeof node === 'object') {
const record = node as Record<string, unknown>
const id = record['@id']
if (typeof id === 'string') {
if (Object.keys(record).length === 1) references.push(id)
else defined.add(id)
}
Object.values(record).forEach(walk)
}
}
walk(value)
return { defined, references }
}

View File

@@ -28,7 +28,6 @@ import {
ModelLibrarySidebarTab,
NodeLibrarySidebarTab,
NodeLibrarySidebarTabV2,
SidebarTab,
WorkflowsSidebarTab
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
@@ -71,7 +70,6 @@ class ComfyPropertiesPanel {
}
class ComfyMenu {
private _appsTab: SidebarTab | null = null
private _assetsTab: AssetsSidebarTab | null = null
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
@@ -106,11 +104,6 @@ class ComfyMenu {
return this._nodeLibraryTabV2
}
get appsTab() {
this._appsTab ??= new SidebarTab(this.page, 'apps')
return this._appsTab
}
get assetsTab() {
this._assetsTab ??= new AssetsSidebarTab(this.page)
return this._assetsTab

View File

@@ -4,7 +4,7 @@ import { expect } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { TestIds } from '@e2e/fixtures/selectors'
export class SidebarTab {
class SidebarTab {
public readonly tabButton: Locator
public readonly selectedTabButton: Locator

View File

@@ -1,40 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* The graph/app view-mode toggle and its workflow actions dropdown.
* A separate instance mounts in each host - the subgraph breadcrumb (graph
* mode) and the app-mode center panel - and unmounts as the mode flips.
*/
export class WorkflowActionsDropdown {
/** The segmented graph/app toggle hosting the workflow actions trigger. */
public readonly viewModeToggle: Locator
/** The active segment; opens the workflow actions menu. */
public readonly trigger: Locator
/** The inactive segment that switches into app mode. */
public readonly enterAppModeSegment: Locator
/** The inactive segment that switches back to the node graph. */
public readonly enterGraphSegment: Locator
/** The workflow actions dropdown menu. */
public readonly menu: Locator
constructor(page: Page) {
this.viewModeToggle = page.getByTestId(
TestIds.workflowActions.viewModeToggle
)
this.trigger = this.triggerIn(this.viewModeToggle)
this.enterAppModeSegment = this.viewModeToggle.getByRole('button', {
name: 'Enter app mode'
})
this.enterGraphSegment = this.viewModeToggle.getByRole('button', {
name: 'Enter node graph'
})
this.menu = page.getByRole('menu', { name: 'Workflow actions' })
}
/** The trigger as rendered inside a specific mode's host. */
triggerIn(host: Locator): Locator {
return host.getByRole('button', { name: 'Workflow actions' })
}
}

View File

@@ -4,7 +4,6 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
import { WorkflowActionsDropdown } from '@e2e/fixtures/components/WorkflowActionsDropdown'
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
@@ -20,7 +19,6 @@ export class AppModeHelper {
readonly outputHistory: OutputHistoryComponent
readonly steps: BuilderStepsHelper
readonly widgets: AppModeWidgetHelper
readonly workflowActions: WorkflowActionsDropdown
/** The "Connect an output" popover shown when saving without outputs. */
public readonly connectOutputPopover: Locator
@@ -79,7 +77,6 @@ export class AppModeHelper {
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.steps = new BuilderStepsHelper(comfyPage)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.workflowActions = new WorkflowActionsDropdown(comfyPage.page)
this.connectOutputPopover = this.page.getByTestId(
TestIds.builder.connectOutputPopover
@@ -188,7 +185,10 @@ export class AppModeHelper {
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {})
await this.workflowActions.trigger.click()
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
.click()
await this.page
.getByRole('menuitem', { name: /Build app|Edit app/ })
.click()

View File

@@ -11,7 +11,6 @@ import {
WORKSPACE_FEATURE_FLAG
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { mockWorkspaceTokenMint } from '@e2e/fixtures/utils/workspaceMocks'
interface RoleChangeRequest {
url: string
@@ -93,7 +92,9 @@ export class CloudWorkspaceMockHelper {
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, TEAM_WORKSPACE)
await page.route('**/api/auth/token', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/workspaces', (r) =>

Some files were not shown because too many files have changed in this diff Show More