Compare commits

...

14 Commits

Author SHA1 Message Date
Mobeen Abdullah
2d511b0f14 refactor(website): centralize breadcrumb @id in a breadcrumbId helper
Extract breadcrumbId(pageUrl) alongside organizationId/websiteId so the
breadcrumb @id format lives in one place instead of three inline template
literals, keeping the @graph links consistent if the format changes.
2026-07-07 03:31:51 +05:00
Mobeen Abdullah
ed8962f0f3 refactor(website): tidy customer story JSON-LD helpers
Dedupe the site-url derivation into siteUrlFrom, single-source the
LinkedIn profile through externalLinks, and derive the collection
ItemList assertion from the rendered story cards instead of a hardcoded
count.
2026-07-03 01:19:05 +05:00
Mobeen Abdullah
1ee281b111 Merge branch 'main' into feat/website-customer-stories-json-ld 2026-07-03 00:36:06 +05:00
Mobeen Abdullah
c62e48ec12 Merge branch 'feat/website-customer-stories-education' into feat/website-customer-stories-json-ld 2026-07-03 00:33:29 +05:00
Mobeen Abdullah
7be0da3ec5 Merge branch 'main' into feat/website-customer-stories-education 2026-07-03 00:25:49 +05:00
Mobeen Abdullah
753c12783d fix(website): cap customer article width and add site-bg-soft utility
Cap the customer story article at the site container width (max-w-9xl) so it
stops stretching on ultra-wide/4K screens, and expose the soft page background
as a proper theme color (--color-site-bg-soft) so components use the
bg-site-bg-soft utility instead of the raw bg-(--site-bg-soft) syntax.
2026-07-03 00:18:41 +05:00
Mobeen Abdullah
7c9f23eb0f feat(website): add JSON-LD structured data for customer pages
Add JsonLdGraph component and customerStoryJsonLd builder, wire structured
data into the customers listing and story detail pages (en + zh-CN), and
cover it with unit and e2e tests.
2026-07-03 00:02:46 +05:00
Mobeen Abdullah
fc44d573ca fix(website): serve Xindi workflow downloads from a cache-fresh path
Point both Xindi workflow downloads at a new workflows/ path whose objects
carry Content-Disposition: attachment. A metadata-only header change on the
existing objects kept the same generation/ETag, so the CDN revalidated with
304 and never picked up the new header; a fresh path is a guaranteed cache
miss and downloads immediately.
2026-07-02 20:00:04 +05:00
Mobeen Abdullah
504a8facac fix(website): address review feedback on education customer stories
- Harden the Vimeo Embed iframe with referrerpolicy and a scoped sandbox
- Re-host Golan Levin's workflow files on media.comfy.org so they download
  instead of opening GitHub blob pages
- Fix "node -based" typo in the UAL CCI story

The Xindi style-transfer workflow download is fixed server-side via
Content-Disposition: attachment on the storage object (no code change).
2026-07-02 19:54:32 +05:00
Mobeen Abdullah
cb1e5aade4 fix(website): update Ina story and Xindi/Golan media per review
Rewrite the Ina Conradi story from the updated source, replace Xindi's what's-next image with the wide version and add a video poster, and link the image-source captions and the Augmented Hand Series reference.
2026-07-02 19:17:32 +05:00
Mobeen Abdullah
b6cdd993c2 fix(website): apply review feedback to customer story blocks
Restyle the workflow download as a yellow underline link (no round CTA), fix the Creative Campus Tally URL, let Figure captions and AuthorBio bios contain links, and tighten inline Link whitespace.
2026-07-02 19:17:31 +05:00
Mobeen Abdullah
cfc31c958e test(website): cover new customer story pages
Make the content section-id test locale-aware and add an e2e smoke test for a Creative Campus story's blocks (At a glance, workflow download, education CTA).
2026-07-02 02:45:05 +05:00
Mobeen Abdullah
6c7ee5f14e feat(website): add five Creative Campus customer stories
Add the Xindi Zhang, Ina Conradi, Golan Levin, Kathy Smith, and UAL CCI stories (English) to the customers collection, ordered after the existing five. Assets are hosted on media.comfy.org.
2026-07-02 02:44:52 +05:00
Mobeen Abdullah
937ae2f7cd feat(website): add article blocks for education customer stories
Add reusable MDX blocks (Embed, Video, Download, AuthorBio, EducationCta, AtAGlance, Link, Heading4), register them in CustomerArticle, and make Quote's name optional for unattributed pull-quotes.
2026-07-02 02:44:37 +05:00
12 changed files with 529 additions and 9 deletions

View File

@@ -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/)
})
})

View File

@@ -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)
})
})

View 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)} />

View File

@@ -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',

View File

@@ -3478,6 +3478,7 @@ const translations = {
},
// Customers page
'customers.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'customers.hero.label': {
en: 'CUSTOMER STORIES',
'zh-CN': '客户故事'

View File

@@ -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 && (

View File

@@ -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 />

View File

@@ -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}

View File

@@ -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 />

View File

@@ -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}

View 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'
)
})
})

View 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
}))
}
]
}
}