diff --git a/demo/site/src/app/[visibility]/[domain]/[language]/news/[slug]/fragment.ts b/demo/site/src/app/[visibility]/[domain]/[language]/news/[slug]/fragment.ts index 786ce3a6035..ed0b05654aa 100644 --- a/demo/site/src/app/[visibility]/[domain]/[language]/news/[slug]/fragment.ts +++ b/demo/site/src/app/[visibility]/[domain]/[language]/news/[slug]/fragment.ts @@ -4,7 +4,10 @@ export const fragment = gql` fragment NewsDetailPage on News { title image + date createdAt + updatedAt + slug content } `; diff --git a/demo/site/src/app/[visibility]/[domain]/[language]/news/[slug]/page.tsx b/demo/site/src/app/[visibility]/[domain]/[language]/news/[slug]/page.tsx index e4519e5543b..89761ab589a 100644 --- a/demo/site/src/app/[visibility]/[domain]/[language]/news/[slug]/page.tsx +++ b/demo/site/src/app/[visibility]/[domain]/[language]/news/[slug]/page.tsx @@ -1,11 +1,13 @@ export const dynamic = "error"; -import { gql } from "@dextinity/site-nextjs"; +import { gql, JsonLd } from "@dextinity/site-nextjs"; import type { GQLNewsContentScopeInput } from "@src/graphql.generated"; import type { VisibilityParam } from "@src/middleware/domainRewrite"; import { createGraphQLFetch } from "@src/util/graphQLClient"; import { setVisibilityParam } from "@src/util/ServerContext"; +import { buildArticle } from "@src/util/structuredData/buildArticle"; import { notFound } from "next/navigation"; +import type { Article } from "schema-dts"; import { Content } from "./content"; import { fragment } from "./fragment"; @@ -14,6 +16,7 @@ import type { GQLNewsDetailPageQuery, GQLNewsDetailPageQueryVariables } from "./ export default async function NewsDetailPage({ params }: PageProps<"/[visibility]/[domain]/[language]/news/[slug]">) { const { domain, language, slug, visibility } = await params; setVisibilityParam(visibility as VisibilityParam); + const scope = { domain, language }; const graphqlFetch = createGraphQLFetch(); const data = await graphqlFetch( @@ -26,12 +29,17 @@ export default async function NewsDetailPage({ params }: PageProps<"/[visibility } ${fragment} `, - { slug, scope: { domain: domain, language: language } as GQLNewsContentScopeInput }, + { slug, scope: scope as GQLNewsContentScopeInput }, ); if (data.newsBySlug === null) { notFound(); } - return ; + return ( + <> + data={buildArticle({ news: data.newsBySlug, scope })} /> + + + ); } diff --git a/demo/site/src/app/[visibility]/[domain]/[language]/news/page.tsx b/demo/site/src/app/[visibility]/[domain]/[language]/news/page.tsx index 9f581630c03..427861e47f1 100644 --- a/demo/site/src/app/[visibility]/[domain]/[language]/news/page.tsx +++ b/demo/site/src/app/[visibility]/[domain]/[language]/news/page.tsx @@ -1,12 +1,27 @@ export const dynamic = "error"; +import { JsonLd } from "@dextinity/site-nextjs"; import type { VisibilityParam } from "@src/middleware/domainRewrite"; import { NewsPage } from "@src/news/NewsPage"; import { fetchNewsList } from "@src/news/NewsPage.loader"; import { setVisibilityParam } from "@src/util/ServerContext"; +import { buildNewsItemList } from "@src/util/structuredData/buildNewsItemList"; +import type { ItemList } from "schema-dts"; export default async function NewsIndexPage({ params }: PageProps<"/[visibility]/[domain]/[language]/news">) { const { visibility, domain, language } = await params; setVisibilityParam(visibility as VisibilityParam); - return ; + + const scope = { domain, language }; + const initialData = await fetchNewsList({ scope, limit: 2 }); + + // Only the initially rendered page is encoded — client-side "Load more" items are not part of the ItemList. + const itemList = buildNewsItemList({ items: initialData.nodes, scope }); + + return ( + <> + data={itemList} /> + + + ); } diff --git a/demo/site/src/news/blocks/NewsListBlock.loader.ts b/demo/site/src/news/blocks/NewsListBlock.loader.ts index 270a1ea2021..ee126447bed 100644 --- a/demo/site/src/news/blocks/NewsListBlock.loader.ts +++ b/demo/site/src/news/blocks/NewsListBlock.loader.ts @@ -1,5 +1,6 @@ import { type BlockLoaderOptions, gql } from "@dextinity/site-nextjs"; import type { NewsListBlockData } from "@src/blocks.generated"; +import { buildNewsItemList } from "@src/util/structuredData/buildNewsItemList"; import type { GQLNewsListBlockQuery, GQLNewsListBlockQueryVariables } from "./NewsListBlock.loader.generated"; @@ -7,7 +8,7 @@ export type LoadedData = Awaited>; export const loader = async ({ blockData, graphQLFetch }: BlockLoaderOptions) => { if (blockData.ids.length === 0) { - return []; + return { news: [], structuredData: null }; } const data = await graphQLFetch( @@ -31,5 +32,9 @@ export const loader = async ({ blockData, graphQLFetch }: BlockLoaderOptions 0 ? buildNewsItemList({ items: news, scope: news[0].scope }) : null; + + return { news, structuredData }; }; diff --git a/demo/site/src/news/blocks/NewsListBlock.tsx b/demo/site/src/news/blocks/NewsListBlock.tsx index e953367719e..ee99d52fa25 100644 --- a/demo/site/src/news/blocks/NewsListBlock.tsx +++ b/demo/site/src/news/blocks/NewsListBlock.tsx @@ -1,31 +1,37 @@ -import { type PropsWithData, withPreview } from "@dextinity/site-nextjs"; +import { JsonLd, type PropsWithData, withPreview } from "@dextinity/site-nextjs"; import type { NewsListBlockData } from "@src/blocks.generated"; import { createSitePath } from "@src/util/createSitePath"; import Link from "next/link"; +import type { ItemList } from "schema-dts"; import type { LoadedData } from "./NewsListBlock.loader"; export const NewsListBlock = withPreview( - ({ data: { loaded: newsList } }: PropsWithData) => { - if (newsList.length === 0) { + ({ data: { loaded } }: PropsWithData) => { + const { news, structuredData } = loaded; + + if (news.length === 0) { return null; } return ( -
    - {newsList.map((news) => ( -
  1. - - {news.title} - -
  2. - ))} -
+ <> + {structuredData && data={structuredData} />} +
    + {news.map((item) => ( +
  1. + + {item.title} + +
  2. + ))} +
+ ); }, { label: "News List" }, diff --git a/demo/site/src/organization/OrganizationJsonLd.tsx b/demo/site/src/organization/OrganizationJsonLd.tsx index e7bda319812..f45a37eaf46 100644 --- a/demo/site/src/organization/OrganizationJsonLd.tsx +++ b/demo/site/src/organization/OrganizationJsonLd.tsx @@ -1,27 +1,12 @@ import { JsonLd } from "@dextinity/site-nextjs"; import type { PublicSiteConfig } from "@src/site-configs"; -import type { Organization, WithContext } from "schema-dts"; +import { buildOrganization } from "@src/util/structuredData/buildOrganization"; +import type { Organization } from "schema-dts"; interface Props { siteConfig: PublicSiteConfig; } -function toAbsoluteUrl(url: string, siteUrl: string): string { - return new URL(url, siteUrl).toString(); -} - export function OrganizationJsonLd({ siteConfig }: Props) { - const { organization, url: siteUrl } = siteConfig; - - const data: WithContext = { - "@context": "https://schema.org", - "@type": "Organization", - name: organization.name, - url: organization.url ?? siteUrl, - ...(organization.logo ? { logo: toAbsoluteUrl(organization.logo, siteUrl) } : {}), - ...(organization.sameAs?.length ? { sameAs: organization.sameAs } : {}), - ...(organization.description ? { description: organization.description } : {}), - }; - - return data={data} />; + return data={buildOrganization(siteConfig)} />; } diff --git a/demo/site/src/util/getSiteConfigs.ts b/demo/site/src/util/getSiteConfigs.ts new file mode 100644 index 00000000000..612908e2376 --- /dev/null +++ b/demo/site/src/util/getSiteConfigs.ts @@ -0,0 +1,22 @@ +import type { PublicSiteConfig } from "@src/site-configs"; + +let siteConfigs: PublicSiteConfig[]; + +export function getSiteConfigs() { + if (!siteConfigs) { + const json = process.env.PUBLIC_SITE_CONFIGS; + if (!json) { + throw new Error("process.env.PUBLIC_SITE_CONFIGS must be set."); + } + siteConfigs = JSON.parse(atob(json)) as PublicSiteConfig[]; + } + return siteConfigs; +} + +export function getSiteConfigForDomain(domain: string) { + const siteConfig = getSiteConfigs().find((siteConfig) => siteConfig.scope.domain === domain); + if (!siteConfig) { + throw new Error(`SiteConfig not found for domain ${domain}`); + } + return siteConfig; +} diff --git a/demo/site/src/util/siteConfig.ts b/demo/site/src/util/siteConfig.ts index 51cabb2f4dc..6fc840f301c 100644 --- a/demo/site/src/util/siteConfig.ts +++ b/demo/site/src/util/siteConfig.ts @@ -1,7 +1,10 @@ import { previewParams } from "@dextinity/site-nextjs/server"; -import type { PublicSiteConfig } from "@src/site-configs"; import { headers } from "next/headers"; +import { getSiteConfigs } from "./getSiteConfigs"; + +export { getSiteConfigForDomain, getSiteConfigs } from "./getSiteConfigs"; + export function getHostByHeaders(headers: Headers) { const host = headers.get("x-forwarded-host") ?? headers.get("host"); if (!host) { @@ -10,14 +13,6 @@ export function getHostByHeaders(headers: Headers) { return host; } -export function getSiteConfigForDomain(domain: string) { - const siteConfig = getSiteConfigs().find((siteConfig) => siteConfig.scope.domain === domain); - if (!siteConfig) { - throw new Error(`SiteConfig not found for domain ${domain}`); - } - return siteConfig; -} - export async function getSiteConfigForHost(host: string) { const sitePreviewParams = await previewParams({ skipDraftModeCheck: true }); if (sitePreviewParams?.scope) { @@ -29,18 +24,6 @@ export async function getSiteConfigForHost(host: string) { return getSiteConfigs().find((siteConfig) => siteConfig.domains.main === host || siteConfig.domains.preliminary === host); } -let siteConfigs: PublicSiteConfig[]; -export function getSiteConfigs() { - if (!siteConfigs) { - const json = process.env.PUBLIC_SITE_CONFIGS; - if (!json) { - throw new Error("process.env.PUBLIC_SITE_CONFIGS must be set."); - } - siteConfigs = JSON.parse(atob(json)) as PublicSiteConfig[]; - } - return siteConfigs; -} - // Used for getting SiteConfig in server-components where params is not available (e.g. sitemap, not-found - see https://github.com/vercel/next.js/discussions/43179) export async function getSiteConfig() { const host = getHostByHeaders(await headers()); diff --git a/demo/site/src/util/structuredData/buildArticle.ts b/demo/site/src/util/structuredData/buildArticle.ts new file mode 100644 index 00000000000..e9a18a6535e --- /dev/null +++ b/demo/site/src/util/structuredData/buildArticle.ts @@ -0,0 +1,38 @@ +import type { DamImageBlockData } from "@src/blocks.generated"; +import type { ContentScope } from "@src/site-configs"; +import { createSitePath } from "@src/util/createSitePath"; +import { getSiteConfigForDomain } from "@src/util/getSiteConfigs"; +import type { Article, WithContext } from "schema-dts"; + +import { buildOrganizationNode } from "./buildOrganization"; +import { damImageToAbsoluteUrl } from "./damImageToAbsoluteUrl"; + +type BuildArticleOptions = { + news: { + title: string; + image: DamImageBlockData; + date: string; + updatedAt: string; + slug: string; + }; + scope: ContentScope; +}; + +export function buildArticle({ news, scope }: BuildArticleOptions): WithContext
{ + const siteConfig = getSiteConfigForDomain(scope.domain); + const organization = buildOrganizationNode(siteConfig); + const image = damImageToAbsoluteUrl(news.image, siteConfig.url); + const detailUrl = `${siteConfig.url}${createSitePath({ scope: { language: scope.language }, path: `/news/${news.slug}` })}`; + + return { + "@context": "https://schema.org", + "@type": "Article", + headline: news.title, + ...(image ? { image } : {}), + datePublished: news.date, + dateModified: news.updatedAt, + author: organization, + publisher: organization, + mainEntityOfPage: detailUrl, + }; +} diff --git a/demo/site/src/util/structuredData/buildNewsItemList.ts b/demo/site/src/util/structuredData/buildNewsItemList.ts new file mode 100644 index 00000000000..156d5bbadcd --- /dev/null +++ b/demo/site/src/util/structuredData/buildNewsItemList.ts @@ -0,0 +1,30 @@ +import type { ContentScope } from "@src/site-configs"; +import { createSitePath } from "@src/util/createSitePath"; +import { getSiteConfigForDomain } from "@src/util/getSiteConfigs"; +import type { ItemList, WithContext } from "schema-dts"; + +type NewsItemListEntry = { + title: string; + slug: string; + scope: { language: string }; +}; + +type BuildNewsItemListOptions = { + items: NewsItemListEntry[]; + scope: ContentScope; +}; + +export function buildNewsItemList({ items, scope }: BuildNewsItemListOptions): WithContext { + const siteUrl = getSiteConfigForDomain(scope.domain).url; + + return { + "@context": "https://schema.org", + "@type": "ItemList", + itemListElement: items.map((item, index) => ({ + "@type": "ListItem", + position: index + 1, + name: item.title, + url: `${siteUrl}${createSitePath({ scope: { language: item.scope.language }, path: `/news/${item.slug}` })}`, + })), + }; +} diff --git a/demo/site/src/util/structuredData/buildOrganization.ts b/demo/site/src/util/structuredData/buildOrganization.ts new file mode 100644 index 00000000000..e5cd1fe9b6c --- /dev/null +++ b/demo/site/src/util/structuredData/buildOrganization.ts @@ -0,0 +1,26 @@ +import type { PublicSiteConfig } from "@src/site-configs"; +import type { Organization, WithContext } from "schema-dts"; + +// schema-dts types `Organization` as a union that includes `string`; the builders only ever produce the object form. +type OrganizationNode = Exclude; + +function toAbsoluteUrl(url: string, siteUrl: string): string { + return new URL(url, siteUrl).toString(); +} + +export function buildOrganizationNode(siteConfig: PublicSiteConfig): OrganizationNode { + const { organization, url: siteUrl } = siteConfig; + + return { + "@type": "Organization", + name: organization.name, + url: organization.url ?? siteUrl, + ...(organization.logo ? { logo: toAbsoluteUrl(organization.logo, siteUrl) } : {}), + ...(organization.sameAs?.length ? { sameAs: organization.sameAs } : {}), + ...(organization.description ? { description: organization.description } : {}), + }; +} + +export function buildOrganization(siteConfig: PublicSiteConfig): WithContext { + return { "@context": "https://schema.org", ...buildOrganizationNode(siteConfig) }; +} diff --git a/demo/site/src/util/structuredData/damImageToAbsoluteUrl.ts b/demo/site/src/util/structuredData/damImageToAbsoluteUrl.ts new file mode 100644 index 00000000000..d97890bda92 --- /dev/null +++ b/demo/site/src/util/structuredData/damImageToAbsoluteUrl.ts @@ -0,0 +1,23 @@ +import { generateImageUrl } from "@dextinity/site-nextjs"; +import type { DamImageBlockData } from "@src/blocks.generated"; + +function damImageToUrl(image: DamImageBlockData): string | undefined { + const props = image.block?.props; + + if (!props) { + return undefined; + } + + if ("urlTemplate" in props && props.damFile?.image) { + const { width, height } = props.damFile.image; + return generateImageUrl({ src: props.urlTemplate, width }, width / height); + } + + return props.damFile?.fileUrl; +} + +export function damImageToAbsoluteUrl(image: DamImageBlockData, siteUrl: string): string | undefined { + const url = damImageToUrl(image); + + return url ? new URL(url, siteUrl).toString() : undefined; +}