mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-11 01:28:03 +00:00
## 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.
130 lines
3.4 KiB
TypeScript
130 lines
3.4 KiB
TypeScript
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()
|