## 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.
@comfyorg/website
Marketing/brand website built with Astro + Vue.
Ashby careers integration
/careers and /zh-CN/careers are rendered from Ashby's public job board
API at build time. Data flow:
src/pages/careers.astroawaitsfetchRolesForBuild()during the Astro build.src/utils/ashby.tscallsGET https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=false, validates the envelope and each posting with Zod (src/utils/ashby.schema.ts), and maps to the domain type insrc/data/roles.ts.- On any failure (network, HTTP 4xx/5xx, envelope schema drift),
the fetcher falls back to the committed JSON snapshot at
src/data/ashby-roles.snapshot.json. src/utils/ashby.ci.tsemits GitHub Actions annotations and a$GITHUB_STEP_SUMMARYblock so stale fetches are visible on green builds.
Required environment variables
Both are build-time only. Never prefix with PUBLIC_ (Astro would
inline that into the client bundle).
| Name | Purpose | Default (when unset) |
|---|---|---|
WEBSITE_ASHBY_API_KEY |
Ashby API key (Basic auth) | Build uses the committed snapshot |
WEBSITE_ASHBY_JOB_BOARD_NAME |
Ashby public job board slug | Build uses the committed snapshot |
CI wiring (manual step — required)
This repo's .github/workflows/*.yaml changes cannot be pushed by a
GitHub App. A maintainer must apply the following edits once:
.github/workflows/ci-website-build.yaml — pass the env into the
build step and run the unit tests before it:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run website unit tests
run: pnpm --filter @comfyorg/website test:unit
- name: Build website
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
run: pnpm --filter @comfyorg/website build
- name: Verify API key is not leaked into build output
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
run: |
set +x
if [ -z "${WEBSITE_ASHBY_API_KEY:-}" ]; then
echo "Secret not available in this run; skipping leak check."
exit 0
fi
# grep -rlF prints only file paths (never match content).
MATCHES=$(grep -rlF --exclude-dir=node_modules --null \
-e "$WEBSITE_ASHBY_API_KEY" apps/website/dist/ 2>/dev/null \
| tr '\0' '\n' || true)
if [ -n "$MATCHES" ]; then
echo "::error title=Ashby API key leaked into build output::$MATCHES"
exit 1
fi
.github/workflows/ci-vercel-website-preview.yaml — add the
two env vars to the top-level env: block so vercel build (both
deploy-preview and deploy-production jobs) sees them:
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
VERCEL_SCOPE: comfyui
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
The secret must also be added to the Vercel project environment
(vercel env add WEBSITE_ASHBY_API_KEY … or via the Vercel UI) so
that vercel build in the preview job has access to it.
Fork PRs do not exercise this path: ci-vercel-website-preview.yaml
receives an empty VERCEL_TOKEN for forks and fails at vercel pull
before the build runs. Fork-safe PR interactions (the preview-URL
comment) are handled by pr-vercel-website-preview.yaml.
Refreshing the snapshot
When a maintainer wants to update the committed snapshot (e.g. after onboarding/offboarding roles):
WEBSITE_ASHBY_API_KEY=… WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org \
pnpm --filter @comfyorg/website ashby:refresh-snapshot
git commit apps/website/src/data/ashby-roles.snapshot.json
The script exits non-zero on any non-fresh outcome so stale/empty snapshots can't be accidentally committed.
Cloud nodes integration
/cloud/supported-nodes (and /zh-CN/) lists custom-node packs preinstalled on Comfy Cloud, joined with public metadata from the ComfyUI Custom Node Registry (api.comfy.org). See src/pages/cloud/supported-nodes/AGENTS.md for the build pipeline, source-file map, and key invariants.
Build-time env var: WEBSITE_CLOUD_API_KEY (Cloud /api/object_info auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
Production strictness
src/utils/cloudNodes.build.ts throws when fetchCloudNodesForBuild() returns
{ status: 'stale' } and process.env.VERCEL_ENV === 'production'. This
prevents the production deploy from silently shipping an out-of-date snapshot
when the Cloud API is unreachable or WEBSITE_CLOUD_API_KEY is missing. Preview
and local builds continue to use the committed snapshot with a warning
annotation.
Required GitHub Actions / Vercel secrets
| Name | Where | Purpose |
|---|---|---|
WEBSITE_CLOUD_API_KEY |
GitHub Actions repo secret + Vercel project env | Auth for Cloud /api/object_info. Required for fresh production data. |
The Release: Website workflow uses the GitHub Actions secret to regenerate
apps/website/src/data/cloud-nodes.snapshot.json via
.github/actions/cloud-nodes-pull/action.yaml. The Vercel environment value is
read at build time by vercel build in ci-vercel-website-preview.yaml; the
deploy-production job hard-fails before vercel build --prod if the secret
is missing.
Refreshing the snapshot
To update the committed snapshot manually (e.g. after onboarding new packs to Comfy Cloud):
WEBSITE_CLOUD_API_KEY=… \
pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
git commit apps/website/src/data/cloud-nodes.snapshot.json
The script exits non-zero on any non-fresh outcome so stale/empty snapshots
can't be accidentally committed. Otherwise the Release: Website GitHub
Actions workflow runs the same step on every manual dispatch and opens a PR
with the refreshed snapshot.
HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
<script
src="https://js-na2.hsforms.net/forms/embed/developer/244637579.js"
defer
></script>
<div
class="hs-form-html"
data-region="na2"
data-form-id="94e05eab-1373-47f7-ab5e-d84f9e6aa262"
data-portal-id="244637579"
></div>
The localized /zh-CN/contact page uses the same portal and script with form
ID 6885750c-02ef-4aa2-ba0d-213be9cccf93.
This keeps submission handling, validation, anti-spam updates, and field
configuration in HubSpot. The local implementation in
src/components/contact/HubspotFormEmbed.vue only loads the hosted script and
renders the documented embed container.
Scripts
pnpm dev— Astro dev serverpnpm build— production build todist/pnpm typecheck—astro checkpnpm test:unit— Vitest unit testspnpm test:e2e— Playwright E2E tests (requirespnpm buildfirst)pnpm ashby:refresh-snapshot— refresh the committed careers snapshotpnpm cloud-nodes:refresh-snapshot— refresh the committed cloud nodes snapshot