mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
Compare commits
14 Commits
codex/subg
...
feat/websi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d511b0f14 | ||
|
|
ed8962f0f3 | ||
|
|
1ee281b111 | ||
|
|
c62e48ec12 | ||
|
|
7be0da3ec5 | ||
|
|
753c12783d | ||
|
|
7c9f23eb0f | ||
|
|
fc44d573ca | ||
|
|
504a8facac | ||
|
|
cb1e5aade4 | ||
|
|
b6cdd993c2 | ||
|
|
cfc31c958e | ||
|
|
6c7ee5f14e | ||
|
|
937ae2f7cd |
@@ -105,4 +105,27 @@ test.describe('Customer story detail @smoke', () => {
|
||||
page.getByRole('link', { name: /Explore the Education Program/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('emits one connected JSON-LD graph describing the story as an Article', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
|
||||
const blocks = await page
|
||||
.locator('script[type="application/ld+json"]')
|
||||
.allTextContents()
|
||||
|
||||
// Single self-contained @graph, so the site-wide Organization/WebSite are
|
||||
// not duplicated (guards the head-slot triple-render regression too).
|
||||
expect(blocks).toHaveLength(1)
|
||||
|
||||
const graph = JSON.parse(blocks[0])['@graph'] as Record<string, unknown>[]
|
||||
const types = graph.map((node) => node['@type'])
|
||||
expect(types.filter((type) => type === 'Organization')).toHaveLength(1)
|
||||
expect(types).toContain('Article')
|
||||
expect(types).toContain('BreadcrumbList')
|
||||
|
||||
const article = graph.find((node) => node['@type'] === 'Article')
|
||||
expect(article?.headline).toMatch(/Series Entertainment/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,4 +30,40 @@ test.describe('Customers @smoke', () => {
|
||||
expect(heightWhileUnloaded).not.toBeNull()
|
||||
expect(heightWhileUnloaded!).toBeGreaterThan(100)
|
||||
})
|
||||
|
||||
test('emits one connected JSON-LD graph describing the story collection', async ({
|
||||
page
|
||||
}) => {
|
||||
const blocks = await page
|
||||
.locator('script[type="application/ld+json"]')
|
||||
.allTextContents()
|
||||
|
||||
// A single self-contained @graph; the site-wide Organization/WebSite are
|
||||
// opted out here so they are not duplicated (also guards the head-slot
|
||||
// triple-render regression).
|
||||
expect(blocks).toHaveLength(1)
|
||||
|
||||
const graph = JSON.parse(blocks[0])['@graph'] as Record<string, unknown>[]
|
||||
const types = graph.map((node) => node['@type'])
|
||||
expect(types.filter((type) => type === 'Organization')).toHaveLength(1)
|
||||
expect(types).toContain('CollectionPage')
|
||||
|
||||
// Tie the list length to the story cards actually rendered, so the test
|
||||
// tracks real content rather than a hardcoded count.
|
||||
const cardSlugs = await page
|
||||
.locator('a[href^="/customers/"]')
|
||||
.evaluateAll((links) => [
|
||||
...new Set(
|
||||
links
|
||||
.map((link) => link.getAttribute('href'))
|
||||
.filter((href): href is string =>
|
||||
/^\/customers\/[a-z0-9-]+$/.test(href ?? '')
|
||||
)
|
||||
)
|
||||
])
|
||||
expect(cardSlugs.length).toBeGreaterThan(0)
|
||||
|
||||
const list = graph.find((node) => node['@type'] === 'ItemList')
|
||||
expect(list?.itemListElement as unknown[]).toHaveLength(cardSlugs.length)
|
||||
})
|
||||
})
|
||||
|
||||
12
apps/website/src/components/common/JsonLdGraph.astro
Normal file
12
apps/website/src/components/common/JsonLdGraph.astro
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import type { JsonLdGraph } from '../../utils/customerStoryJsonLd'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
interface Props {
|
||||
graph: JsonLdGraph
|
||||
}
|
||||
|
||||
const { graph } = Astro.props
|
||||
---
|
||||
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />
|
||||
@@ -66,6 +66,7 @@ export const externalLinks = {
|
||||
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',
|
||||
|
||||
@@ -3478,6 +3478,7 @@ const translations = {
|
||||
},
|
||||
|
||||
// Customers page
|
||||
'customers.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'customers.hero.label': {
|
||||
en: 'CUSTOMER STORIES',
|
||||
'zh-CN': '客户故事'
|
||||
|
||||
@@ -14,6 +14,7 @@ interface Props {
|
||||
keywords?: string[]
|
||||
ogImage?: string
|
||||
noindex?: boolean
|
||||
includeDefaultJsonLd?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -22,6 +23,9 @@ const {
|
||||
keywords,
|
||||
ogImage = 'https://media.comfy.org/website/comfy.webp',
|
||||
noindex = false,
|
||||
// Pages that emit their own connected @graph (e.g. the customer stories) opt
|
||||
// out so the site-wide Organization/WebSite are not duplicated on the page.
|
||||
includeDefaultJsonLd = true,
|
||||
} = Astro.props
|
||||
|
||||
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
|
||||
@@ -100,10 +104,12 @@ const websiteJsonLd = {
|
||||
<meta name="twitter:image" content={ogImageURL.href} />
|
||||
|
||||
<!-- Structured Data -->
|
||||
<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" />
|
||||
|
||||
{includeDefaultJsonLd && (
|
||||
<Fragment>
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
|
||||
</Fragment>
|
||||
)}
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
@@ -123,7 +129,6 @@ const websiteJsonLd = {
|
||||
)}
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
</head>
|
||||
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
|
||||
{gtmEnabled && (
|
||||
|
||||
@@ -3,15 +3,30 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ContactSection from '../components/customers/ContactSection.vue'
|
||||
import FeedbackSection from '../components/customers/FeedbackSection.vue'
|
||||
import HeroSection from '../components/customers/HeroSection.vue'
|
||||
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
|
||||
import StorySection from '../components/customers/StorySection.vue'
|
||||
import VideoSection from '../components/customers/VideoSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import { toCardProps } from '../utils/customers'
|
||||
import {
|
||||
buildCustomersCollectionJsonLd,
|
||||
siteUrlFrom
|
||||
} from '../utils/customerStoryJsonLd'
|
||||
import { loadStories } from '../utils/loadStories'
|
||||
|
||||
const stories = (await loadStories('en')).map(toCardProps)
|
||||
|
||||
const siteUrl = siteUrlFrom(Astro.site)
|
||||
const jsonLd = buildCustomersCollectionJsonLd(stories, {
|
||||
siteUrl,
|
||||
locale: 'en',
|
||||
homeLabel: t('customers.breadcrumb.home', 'en'),
|
||||
collectionLabel: t('nav.customerStories', 'en')
|
||||
})
|
||||
---
|
||||
|
||||
<BaseLayout title="Customer Stories — Comfy">
|
||||
<BaseLayout title="Customer Stories — Comfy" includeDefaultJsonLd={false}>
|
||||
<JsonLdGraph graph={jsonLd} slot="head" />
|
||||
<HeroSection client:load />
|
||||
<StorySection stories={stories} />
|
||||
<FeedbackSection client:load />
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
---
|
||||
import CustomerArticle from '../../components/customers/CustomerArticle.astro'
|
||||
import DetailHeroSection from '../../components/customers/DetailHeroSection.vue'
|
||||
import JsonLdGraph from '../../components/common/JsonLdGraph.astro'
|
||||
import WhatsNextSection from '../../components/customers/WhatsNextSection.vue'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { nextStory, storySlug } from '../../utils/customers'
|
||||
import { buildStoryJsonLd, siteUrlFrom } from '../../utils/customerStoryJsonLd'
|
||||
import { loadStories } from '../../utils/loadStories'
|
||||
|
||||
export async function getStaticPaths() {
|
||||
@@ -15,9 +18,26 @@ export async function getStaticPaths() {
|
||||
}
|
||||
|
||||
const { entry, next } = Astro.props
|
||||
|
||||
const siteUrl = siteUrlFrom(Astro.site)
|
||||
const jsonLd = buildStoryJsonLd(
|
||||
{
|
||||
slug: storySlug(entry.id),
|
||||
title: entry.data.title,
|
||||
description: entry.data.description,
|
||||
cover: entry.data.cover
|
||||
},
|
||||
{
|
||||
siteUrl,
|
||||
locale: 'en',
|
||||
homeLabel: t('customers.breadcrumb.home', 'en'),
|
||||
collectionLabel: t('nav.customerStories', 'en')
|
||||
}
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout title={`${entry.data.title} — Comfy`}>
|
||||
<BaseLayout title={`${entry.data.title} — Comfy`} includeDefaultJsonLd={false}>
|
||||
<JsonLdGraph graph={jsonLd} slot="head" />
|
||||
<DetailHeroSection
|
||||
label={entry.data.category}
|
||||
title={entry.data.title}
|
||||
|
||||
@@ -3,15 +3,30 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ContactSection from '../../components/customers/ContactSection.vue'
|
||||
import FeedbackSection from '../../components/customers/FeedbackSection.vue'
|
||||
import HeroSection from '../../components/customers/HeroSection.vue'
|
||||
import JsonLdGraph from '../../components/common/JsonLdGraph.astro'
|
||||
import StorySection from '../../components/customers/StorySection.vue'
|
||||
import VideoSection from '../../components/customers/VideoSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { toCardProps } from '../../utils/customers'
|
||||
import {
|
||||
buildCustomersCollectionJsonLd,
|
||||
siteUrlFrom
|
||||
} from '../../utils/customerStoryJsonLd'
|
||||
import { loadStories } from '../../utils/loadStories'
|
||||
|
||||
const stories = (await loadStories('zh-CN')).map(toCardProps)
|
||||
|
||||
const siteUrl = siteUrlFrom(Astro.site)
|
||||
const jsonLd = buildCustomersCollectionJsonLd(stories, {
|
||||
siteUrl,
|
||||
locale: 'zh-CN',
|
||||
homeLabel: t('customers.breadcrumb.home', 'zh-CN'),
|
||||
collectionLabel: t('nav.customerStories', 'zh-CN')
|
||||
})
|
||||
---
|
||||
|
||||
<BaseLayout title="客户故事 — Comfy">
|
||||
<BaseLayout title="客户故事 — Comfy" includeDefaultJsonLd={false}>
|
||||
<JsonLdGraph graph={jsonLd} slot="head" />
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<StorySection stories={stories} locale="zh-CN" />
|
||||
<FeedbackSection locale="zh-CN" client:load />
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
---
|
||||
import CustomerArticle from '../../../components/customers/CustomerArticle.astro'
|
||||
import DetailHeroSection from '../../../components/customers/DetailHeroSection.vue'
|
||||
import JsonLdGraph from '../../../components/common/JsonLdGraph.astro'
|
||||
import WhatsNextSection from '../../../components/customers/WhatsNextSection.vue'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { nextStory, storySlug } from '../../../utils/customers'
|
||||
import {
|
||||
buildStoryJsonLd,
|
||||
siteUrlFrom
|
||||
} from '../../../utils/customerStoryJsonLd'
|
||||
import { loadStories } from '../../../utils/loadStories'
|
||||
|
||||
export async function getStaticPaths() {
|
||||
@@ -15,9 +21,26 @@ export async function getStaticPaths() {
|
||||
}
|
||||
|
||||
const { entry, next } = Astro.props
|
||||
|
||||
const siteUrl = siteUrlFrom(Astro.site)
|
||||
const jsonLd = buildStoryJsonLd(
|
||||
{
|
||||
slug: storySlug(entry.id),
|
||||
title: entry.data.title,
|
||||
description: entry.data.description,
|
||||
cover: entry.data.cover
|
||||
},
|
||||
{
|
||||
siteUrl,
|
||||
locale: 'zh-CN',
|
||||
homeLabel: t('customers.breadcrumb.home', 'zh-CN'),
|
||||
collectionLabel: t('nav.customerStories', 'zh-CN')
|
||||
}
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout title={`${entry.data.title} — Comfy`}>
|
||||
<BaseLayout title={`${entry.data.title} — Comfy`} includeDefaultJsonLd={false}>
|
||||
<JsonLdGraph graph={jsonLd} slot="head" />
|
||||
<DetailHeroSection
|
||||
label={entry.data.category}
|
||||
title={entry.data.title}
|
||||
|
||||
161
apps/website/src/utils/customerStoryJsonLd.test.ts
Normal file
161
apps/website/src/utils/customerStoryJsonLd.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type {
|
||||
JsonLdGraph,
|
||||
JsonLdContext,
|
||||
StorySummary
|
||||
} from './customerStoryJsonLd'
|
||||
import {
|
||||
buildCustomersCollectionJsonLd,
|
||||
buildStoryJsonLd
|
||||
} from './customerStoryJsonLd'
|
||||
import { escapeJsonLd } from './escapeJsonLd'
|
||||
|
||||
const enContext: JsonLdContext = {
|
||||
siteUrl: 'https://comfy.org',
|
||||
locale: 'en',
|
||||
homeLabel: 'Home',
|
||||
collectionLabel: 'Customer Stories'
|
||||
}
|
||||
|
||||
const story = {
|
||||
slug: 'series-entertainment',
|
||||
title:
|
||||
'How Series Entertainment Rebuilt Game and Video Production with ComfyUI',
|
||||
description: 'Scaling emotional storytelling across 100,000+ assets.',
|
||||
cover:
|
||||
'https://media.comfy.org/website/customers/series-entertainment/cover.webp'
|
||||
}
|
||||
|
||||
const summaries: StorySummary[] = [
|
||||
{
|
||||
slug: 'series-entertainment',
|
||||
title: 'Series Entertainment',
|
||||
cover: 'https://media.comfy.org/a.webp'
|
||||
},
|
||||
{
|
||||
slug: 'moment-factory',
|
||||
title: 'Moment Factory',
|
||||
cover: 'https://media.comfy.org/b.webp'
|
||||
},
|
||||
{
|
||||
slug: 'ubisoft-chord',
|
||||
title: 'Ubisoft CHORD',
|
||||
cover: 'https://media.comfy.org/c.webp'
|
||||
}
|
||||
]
|
||||
|
||||
function nodeOfType(graph: JsonLdGraph, type: string) {
|
||||
return graph['@graph'].find((node) => node['@type'] === type)
|
||||
}
|
||||
|
||||
describe('buildStoryJsonLd', () => {
|
||||
const graph = buildStoryJsonLd(story, enContext)
|
||||
|
||||
it('emits the five connected node types', () => {
|
||||
const types = graph['@graph'].map((node) => node['@type'])
|
||||
expect(types).toEqual([
|
||||
'Organization',
|
||||
'WebSite',
|
||||
'WebPage',
|
||||
'BreadcrumbList',
|
||||
'Article'
|
||||
])
|
||||
})
|
||||
|
||||
it('links the Article to the page, organization, and image it describes', () => {
|
||||
const article = nodeOfType(graph, 'Article')!
|
||||
const pageUrl = 'https://comfy.org/customers/series-entertainment'
|
||||
expect(article['@id']).toBe(`${pageUrl}#article`)
|
||||
expect(article.headline).toBe(story.title)
|
||||
expect(article.image).toBe(story.cover)
|
||||
expect(article.mainEntityOfPage).toEqual({ '@id': `${pageUrl}#webpage` })
|
||||
expect(article.author).toEqual({ '@id': 'https://comfy.org/#organization' })
|
||||
expect(article.publisher).toEqual({
|
||||
'@id': 'https://comfy.org/#organization'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a raster logo the Organization references by id', () => {
|
||||
const org = nodeOfType(graph, 'Organization')!
|
||||
expect(org['@id']).toBe('https://comfy.org/#organization')
|
||||
expect(org.logo).toMatchObject({
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://comfy.org/web-app-manifest-512x512.png'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a three-step breadcrumb with the final crumb url omitted', () => {
|
||||
const breadcrumb = nodeOfType(graph, 'BreadcrumbList')!
|
||||
const items = breadcrumb.itemListElement as Record<string, unknown>[]
|
||||
expect(items.map((item) => item.position)).toEqual([1, 2, 3])
|
||||
expect(items[0].item).toBe('https://comfy.org')
|
||||
expect(items[1].item).toBe('https://comfy.org/customers')
|
||||
expect(items[2].item).toBeUndefined()
|
||||
expect(items[2].name).toBe(story.title)
|
||||
})
|
||||
|
||||
it('localizes language and url prefix for zh-CN', () => {
|
||||
const zh = buildStoryJsonLd(story, {
|
||||
...enContext,
|
||||
locale: 'zh-CN',
|
||||
homeLabel: '首页',
|
||||
collectionLabel: '客户故事'
|
||||
})
|
||||
const article = nodeOfType(zh, 'Article')!
|
||||
expect(article.inLanguage).toBe('zh-CN')
|
||||
expect(article['@id']).toBe(
|
||||
'https://comfy.org/zh-CN/customers/series-entertainment#article'
|
||||
)
|
||||
const breadcrumb = nodeOfType(zh, 'BreadcrumbList')!
|
||||
const items = breadcrumb.itemListElement as Record<string, unknown>[]
|
||||
expect(items[0].item).toBe('https://comfy.org/zh-CN')
|
||||
expect(items[1].item).toBe('https://comfy.org/zh-CN/customers')
|
||||
})
|
||||
|
||||
it('serializes safely and round-trips through JSON.parse', () => {
|
||||
expect(JSON.parse(escapeJsonLd(graph))).toEqual(graph)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCustomersCollectionJsonLd', () => {
|
||||
const graph = buildCustomersCollectionJsonLd(summaries, enContext)
|
||||
|
||||
it('emits a CollectionPage backed by an ItemList', () => {
|
||||
const types = graph['@graph'].map((node) => node['@type'])
|
||||
expect(types).toEqual([
|
||||
'Organization',
|
||||
'WebSite',
|
||||
'CollectionPage',
|
||||
'BreadcrumbList',
|
||||
'ItemList'
|
||||
])
|
||||
const page = nodeOfType(graph, 'CollectionPage')!
|
||||
expect(page.mainEntity).toEqual({
|
||||
'@id': 'https://comfy.org/customers#itemlist'
|
||||
})
|
||||
})
|
||||
|
||||
it('lists every story with consecutive positions and absolute detail urls', () => {
|
||||
const list = nodeOfType(graph, 'ItemList')!
|
||||
const items = list.itemListElement as Record<string, unknown>[]
|
||||
expect(items).toHaveLength(summaries.length)
|
||||
expect(items.map((item) => item.position)).toEqual([1, 2, 3])
|
||||
expect(items[0].url).toBe(
|
||||
'https://comfy.org/customers/series-entertainment'
|
||||
)
|
||||
expect(items[2].url).toBe('https://comfy.org/customers/ubisoft-chord')
|
||||
})
|
||||
|
||||
it('prefixes detail urls with the locale for zh-CN', () => {
|
||||
const zh = buildCustomersCollectionJsonLd(summaries, {
|
||||
...enContext,
|
||||
locale: 'zh-CN'
|
||||
})
|
||||
const list = nodeOfType(zh, 'ItemList')!
|
||||
const items = list.itemListElement as Record<string, unknown>[]
|
||||
expect(items[0].url).toBe(
|
||||
'https://comfy.org/zh-CN/customers/series-entertainment'
|
||||
)
|
||||
})
|
||||
})
|
||||
208
apps/website/src/utils/customerStoryJsonLd.ts
Normal file
208
apps/website/src/utils/customerStoryJsonLd.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { externalLinks } from '../config/routes'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
|
||||
// Kept free of `astro:content` (like utils/customers.ts) so the builders are
|
||||
// pure data transforms: node-testable now and reusable once the stories are
|
||||
// served from a CMS instead of MDX frontmatter.
|
||||
|
||||
type JsonLdNode = Record<string, unknown> & { '@type': string }
|
||||
|
||||
export interface JsonLdGraph {
|
||||
'@context': 'https://schema.org'
|
||||
'@graph': JsonLdNode[]
|
||||
}
|
||||
|
||||
export interface StorySummary {
|
||||
slug: string
|
||||
title: string
|
||||
cover: string
|
||||
}
|
||||
|
||||
export interface StoryDetail extends StorySummary {
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface JsonLdContext {
|
||||
siteUrl: string
|
||||
locale: Locale
|
||||
homeLabel: string
|
||||
collectionLabel: string
|
||||
}
|
||||
|
||||
// Authoritative social profiles, kept in sync with the site footer via
|
||||
// externalLinks.
|
||||
const SAME_AS = [
|
||||
externalLinks.github,
|
||||
externalLinks.x,
|
||||
externalLinks.youtube,
|
||||
externalLinks.discord,
|
||||
externalLinks.instagram,
|
||||
externalLinks.reddit,
|
||||
externalLinks.linkedin
|
||||
]
|
||||
|
||||
// Normalizes Astro.site (a URL with a trailing slash, or undefined) to a bare
|
||||
// origin the @id/URL builders can append paths to.
|
||||
export function siteUrlFrom(site: URL | undefined): string {
|
||||
return (site?.href ?? 'https://comfy.org/').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function localePrefix(locale: Locale): string {
|
||||
return locale === 'en' ? '' : `/${locale}`
|
||||
}
|
||||
|
||||
function homeUrl({ siteUrl, locale }: JsonLdContext): string {
|
||||
return locale === 'en' ? siteUrl : `${siteUrl}${localePrefix(locale)}`
|
||||
}
|
||||
|
||||
function customersUrl({ siteUrl, locale }: JsonLdContext): string {
|
||||
return `${siteUrl}${localePrefix(locale)}/customers`
|
||||
}
|
||||
|
||||
function detailUrl(context: JsonLdContext, slug: string): string {
|
||||
return `${customersUrl(context)}/${slug}`
|
||||
}
|
||||
|
||||
function organizationId(siteUrl: string): string {
|
||||
return `${siteUrl}/#organization`
|
||||
}
|
||||
|
||||
function websiteId(siteUrl: string): string {
|
||||
return `${siteUrl}/#website`
|
||||
}
|
||||
|
||||
function breadcrumbId(pageUrl: string): string {
|
||||
return `${pageUrl}#breadcrumb`
|
||||
}
|
||||
|
||||
function organizationNode(siteUrl: string): JsonLdNode {
|
||||
return {
|
||||
'@type': 'Organization',
|
||||
'@id': organizationId(siteUrl),
|
||||
name: 'Comfy Org',
|
||||
url: siteUrl,
|
||||
// Raster logo: Google Images does not index SVG for the logo property.
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${siteUrl}/web-app-manifest-512x512.png`,
|
||||
width: 512,
|
||||
height: 512
|
||||
},
|
||||
sameAs: SAME_AS
|
||||
}
|
||||
}
|
||||
|
||||
function websiteNode(siteUrl: string, locale: Locale): JsonLdNode {
|
||||
return {
|
||||
'@type': 'WebSite',
|
||||
'@id': websiteId(siteUrl),
|
||||
name: 'Comfy',
|
||||
url: siteUrl,
|
||||
publisher: { '@id': organizationId(siteUrl) },
|
||||
inLanguage: locale
|
||||
}
|
||||
}
|
||||
|
||||
function breadcrumbNode(
|
||||
pageUrl: string,
|
||||
crumbs: [string, string][]
|
||||
): JsonLdNode {
|
||||
return {
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': breadcrumbId(pageUrl),
|
||||
itemListElement: crumbs.map(([name, item], index) => {
|
||||
const isLast = index === crumbs.length - 1
|
||||
// Google uses the current page URL for the final crumb, so its item is
|
||||
// intentionally omitted.
|
||||
return isLast
|
||||
? { '@type': 'ListItem', position: index + 1, name }
|
||||
: { '@type': 'ListItem', position: index + 1, name, item }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStoryJsonLd(
|
||||
story: StoryDetail,
|
||||
context: JsonLdContext
|
||||
): JsonLdGraph {
|
||||
const { siteUrl, locale } = context
|
||||
const pageUrl = detailUrl(context, story.slug)
|
||||
const webPageId = `${pageUrl}#webpage`
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
organizationNode(siteUrl),
|
||||
websiteNode(siteUrl, locale),
|
||||
{
|
||||
'@type': 'WebPage',
|
||||
'@id': webPageId,
|
||||
url: pageUrl,
|
||||
name: `${story.title} — Comfy`,
|
||||
isPartOf: { '@id': websiteId(siteUrl) },
|
||||
primaryImageOfPage: { '@type': 'ImageObject', url: story.cover },
|
||||
breadcrumb: { '@id': breadcrumbId(pageUrl) },
|
||||
inLanguage: locale
|
||||
},
|
||||
breadcrumbNode(pageUrl, [
|
||||
[context.homeLabel, homeUrl(context)],
|
||||
[context.collectionLabel, customersUrl(context)],
|
||||
[story.title, pageUrl]
|
||||
]),
|
||||
{
|
||||
'@type': 'Article',
|
||||
'@id': `${pageUrl}#article`,
|
||||
headline: story.title,
|
||||
name: story.title,
|
||||
description: story.description,
|
||||
image: story.cover,
|
||||
inLanguage: locale,
|
||||
isPartOf: { '@id': webPageId },
|
||||
mainEntityOfPage: { '@id': webPageId },
|
||||
author: { '@id': organizationId(siteUrl) },
|
||||
publisher: { '@id': organizationId(siteUrl) }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCustomersCollectionJsonLd(
|
||||
stories: StorySummary[],
|
||||
context: JsonLdContext
|
||||
): JsonLdGraph {
|
||||
const { siteUrl, locale } = context
|
||||
const pageUrl = customersUrl(context)
|
||||
const listId = `${pageUrl}#itemlist`
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
organizationNode(siteUrl),
|
||||
websiteNode(siteUrl, locale),
|
||||
{
|
||||
'@type': 'CollectionPage',
|
||||
'@id': `${pageUrl}#webpage`,
|
||||
url: pageUrl,
|
||||
name: context.collectionLabel,
|
||||
isPartOf: { '@id': websiteId(siteUrl) },
|
||||
breadcrumb: { '@id': breadcrumbId(pageUrl) },
|
||||
mainEntity: { '@id': listId },
|
||||
inLanguage: locale
|
||||
},
|
||||
breadcrumbNode(pageUrl, [
|
||||
[context.homeLabel, homeUrl(context)],
|
||||
[context.collectionLabel, pageUrl]
|
||||
]),
|
||||
{
|
||||
'@type': 'ItemList',
|
||||
'@id': listId,
|
||||
itemListElement: stories.map((story, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: detailUrl(context, story.slug),
|
||||
name: story.title
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user