Files
ComfyUI_frontend/apps/website/scripts
Mobeen Abdullah 1815c7f7a4 feat(website): add JSON-LD structured data across the site (#13480)
## Summary

This PR adds schema.org **JSON-LD structured data across the whole
marketing site**, built from one shared, CMS-ready module and gated by a
small CI validator. It replaces the old global block (which had a stale
logo, wrong social links, disconnected nodes, and a head slot that
rendered three times) with a single connected `@graph` on every page.
Structured data only — there is no visual or runtime change for users.
Tracks Linear **FE-1170**.

The design goal was that structured data should be impossible to get
subtly wrong: one place builds it, honesty rules are enforced in code,
and a build-time validator fails the build if any page ships a broken
`@id` graph or a fabricated price/rating.

## Changes

- **One builder, one sink.** `utils/jsonLd.ts` holds pure, node-testable
builders; `components/common/JsonLdGraph.astro` is the single escaped
`<script type="application/ld+json">` sink (prevents `</script>`
breakout XSS).
- **The layout owns the page entity.** `BaseLayout` emits a baseline
`Organization` + `WebSite` + `WebPage` graph on every page from its own
`title`/`description`/canonical props. Enriched pages pass only what is
specific to them: `pageType`, `breadcrumbs`, `mainEntityId`, and
`extraJsonLd` nodes. This makes it impossible for a page's meta tags and
its structured data to drift apart.
- **Corrected site-wide entity.** Raster PNG logo (Google does not index
SVG logos), real `sameAs` handles sourced from the footer links,
`@id`-linked `Organization`/`WebSite`, and the triple-rendered head slot
fixed.
- **Honesty is enforced, not just intended.** No fabricated
`Review`/`AggregateRating`/`Offer`. Pricing offers are parsed only from
plain `$N` copy (a future "Contact us" drops the offer instead of
shipping a garbage price). Third-party node packs and listed models do
**not** claim Comfy Org as author/publisher. `noindex` pages (404,
payment) emit no structured data.
- **CI validator.** `pnpm --filter @comfyorg/website validate:jsonld`
runs over `dist/` in the website build workflow and fails on invalid
JSON, an unresolved `@id`, a fake rating, or an empty/non-numeric offer
price.
- **Breaking:** none.

## Coverage (also a manual QA checklist for the preview)

Every public page carries at least `Organization` + `WebSite` +
`WebPage`. The pages below add a page-specific primary entity, in
**English and zh-CN**:

| Page | Path (example) | Adds to the graph |
|---|---|---|
| Home | `/`, `/zh-CN` | `SoftwareApplication` (ComfyUI, free) +
`SoftwareSourceCode` |
| Download | `/download` | `SoftwareApplication` (ComfyUI desktop) |
| Pricing | `/cloud/pricing` | `Product` + 3 monthly `Offer`s
($20/$35/$100) + Breadcrumb |
| Models catalog | `/p/supported-models` | `CollectionPage` + `ItemList`
(313, lean) + Breadcrumb |
| Model detail | `/p/supported-models/4x-ultrasharp` |
`SoftwareApplication` + `FAQPage` + Breadcrumb |
| Nodes catalog | `/cloud/supported-nodes` | `CollectionPage` +
`ItemList` (58 packs) + Breadcrumb |
| Node-pack detail | `/cloud/supported-nodes/ComfyQR` |
`SoftwareApplication` (+ free `Offer`) + Breadcrumb |
| Demos | `/demos/community-workflows` | `LearningResource` + Breadcrumb
|
| About / Contact | `/about`, `/contact` | `AboutPage` / `ContactPage`
(Org as `mainEntity`) + Breadcrumb |
| Careers | `/careers` | `CollectionPage` + `ItemList` of open roles +
Breadcrumb |
| Affiliates | `/affiliates` | `FAQPage` + Breadcrumb |

Pages deliberately left at the baseline `WebPage` (generic landings,
legal, coming-soon) and pages with **no** structured data (`noindex`:
`/404`, `/payment/*`; redirect URLs) are intentional.

## Review Focus

- **Layout-owns-WebPage design.** `BaseLayout` builds the `WebPage`;
pages contribute only extra nodes. This is the main structural decision
and is what removes meta-vs-schema drift by construction.
- **Honesty guardrails.** Worth confirming: pricing offers, third-party
author omission on packs/models, and that `noindex` pages emit nothing.
- **`@id` and URL consistency.** All cross-page links and `@id`s resolve
to the canonical trailing-slash form; zh-CN breadcrumbs are rooted under
`/zh-CN`; the singleton `WebSite`/`#software` entities carry one
consistent definition across pages and locales.
- **The validator.** It is a bespoke ~100-line script scoped to the
website build job (not the prod deploy). Happy to make it non-blocking
or drop it if the team prefers.
- **Coordination with #13468.** Both branches add
`components/common/JsonLdGraph.astro`. Customer pages are intentionally
excluded here; #13468 can converge onto this shared builder.

## Verification

`astro check` 0 errors · 166 unit tests · `knip` 0 · `eslint` 0 · build
497 pages · validator passes across 500 pages · JSON-LD e2e specs 24/24.
(The 3 pre-existing demo e2e timeouts are an external Arcade-embed flake
on one slug, reproduced identically on `main`.)

## Screenshots

Not applicable — head-only structured data, no visual change. Validate
on the Vercel preview with the Rich Results Test and the schema.org
validator. Note: `@id`/URL values render as `comfy.org` (from
`astro.config` `site`) even on the preview host, which is correct.
2026-07-09 21:03:43 +00:00
..
2026-06-09 20:14:03 +00:00

Website Scripts

refresh-ashby-snapshot.ts

Pulls the latest job postings from Ashby and writes src/data/ashby-roles.snapshot.json. Invoked by the Release: Website GitHub Actions workflow; also runnable locally via pnpm --filter @comfyorg/website ashby:refresh-snapshot.

process-videos.sh

Generates multi-resolution VP9/WebM + H.264/MP4 variants and a poster frame for marketing videos using ffmpeg. Run locally before uploading the outputs to media.comfy.org; this is not wired into CI.

apps/website/scripts/process-videos.sh \
  ./video-sources \
  ./dist/videos \
  "640 960 1280 1920"

Output

For each source video at ./video-sources/foo.mp4, you get:

foo-640.webm   foo-640.mp4
foo-960.webm   foo-960.mp4
foo-1280.webm  foo-1280.mp4
foo-1920.webm  foo-1920.mp4
foo-poster.jpg

The naming convention is enforced by buildVideoSources() in src/utils/video.ts, which the <SiteVideo> Vue component uses to emit <source> URLs.

Pairing with <SiteVideo>

Once the assets are uploaded, render them with:

<SiteVideo
  name="foo"
  base-url="https://media.comfy.org/website/marketing"
  :width="1280"
  :formats="['webm', 'mp4']"
  poster="https://media.comfy.org/website/marketing/foo-poster.jpg"
  autoplay
  loop
/>

<SiteVideo> vs <VideoPlayer>

  • SiteVideo — lightweight multi-source <video> for decorative or autoplay marketing clips. No custom controls, no captions UI.
  • VideoPlayer — full-featured player with custom scrubber, mute, fullscreen, and caption toggles. Use this for content with subtitles or user-driven playback.

If you need both responsive sources and the rich VideoPlayer chrome, the two are not yet combined; either pick one or extend VideoPlayer to accept a source list.

Encoder choices

  • VP9/WebM at CRF 32 — preferred by Chrome and Firefox; smaller files.
  • H.264/MP4 at CRF 23, High profile, +faststart — universal fallback, required for Safari iOS.
  • Poster JPG at q4 — extracted from t=1s when the clip is long enough, otherwise t=0; scaled to 1280w. Use this as the poster attribute so the video shows something while loading.

Why a single resolution per video

<source media="..."> inside <video> is unreliable across browsers (Safari ignores it). The simplest correct strategy is to ship one well-sized resolution and let CSS scale it down on smaller viewports. The script generates multiple widths so you can pick a different one per page (e.g. 1280w for a hero, 640w for a thumbnail), or wire up JavaScript-based selection later if metrics demand it.