Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ export const fragment = gql`
fragment NewsDetailPage on News {
title
image
date
createdAt
updatedAt
slug
content
}
`;
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<GQLNewsDetailPageQuery, GQLNewsDetailPageQueryVariables>(
Expand All @@ -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 <Content news={data.newsBySlug} />;
return (
<>
<JsonLd<Article> data={buildArticle({ news: data.newsBySlug, scope })} />
<Content news={data.newsBySlug} />
</>
);
}
17 changes: 16 additions & 1 deletion demo/site/src/app/[visibility]/[domain]/[language]/news/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <NewsPage scope={{ domain, language }} initialData={await fetchNewsList({ scope: { domain, language }, limit: 2 })} />;

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 (
<>
<JsonLd<ItemList> data={itemList} />
<NewsPage scope={scope} initialData={initialData} />
</>
);
}
9 changes: 7 additions & 2 deletions demo/site/src/news/blocks/NewsListBlock.loader.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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";

export type LoadedData = Awaited<ReturnType<typeof loader>>;

export const loader = async ({ blockData, graphQLFetch }: BlockLoaderOptions<NewsListBlockData>) => {
if (blockData.ids.length === 0) {
return [];
return { news: [], structuredData: null };
}

const data = await graphQLFetch<GQLNewsListBlockQuery, GQLNewsListBlockQueryVariables>(
Expand All @@ -31,5 +32,9 @@ export const loader = async ({ blockData, graphQLFetch }: BlockLoaderOptions<New
{ ids: blockData.ids },
);

return data.newsListByIds;
const news = data.newsListByIds;
// Structured data is built server-side because the block renders inside a client component without access to the site config.
const structuredData = news.length > 0 ? buildNewsItemList({ items: news, scope: news[0].scope }) : null;

return { news, structuredData };
};
40 changes: 23 additions & 17 deletions demo/site/src/news/blocks/NewsListBlock.tsx
Original file line number Diff line number Diff line change
@@ -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<NewsListBlockData & { loaded: LoadedData }>) => {
if (newsList.length === 0) {
({ data: { loaded } }: PropsWithData<NewsListBlockData & { loaded: LoadedData }>) => {
const { news, structuredData } = loaded;

if (news.length === 0) {
return null;
}

return (
<ol>
{newsList.map((news) => (
<li key={news.id}>
<Link
href={createSitePath({
scope: news.scope,
path: `/news/${news.slug}`,
})}
>
{news.title}
</Link>
</li>
))}
</ol>
<>
{structuredData && <JsonLd<ItemList> data={structuredData} />}

@nsams nsams Sep 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it was required in the ticket, but I'm questioning this.
I don't think we should use an ItemList JsonLd here when we have a detail page that has a full Article JsonLd

<ol>
{news.map((item) => (
<li key={item.id}>
<Link
href={createSitePath({
scope: item.scope,
path: `/news/${item.slug}`,
})}
>
{item.title}
</Link>
</li>
))}
</ol>
</>
);
},
{ label: "News List" },
Expand Down
21 changes: 3 additions & 18 deletions demo/site/src/organization/OrganizationJsonLd.tsx
Original file line number Diff line number Diff line change
@@ -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<Organization> = {
"@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 <JsonLd<Organization> data={data} />;
return <JsonLd<Organization> data={buildOrganization(siteConfig)} />;
}
22 changes: 22 additions & 0 deletions demo/site/src/util/getSiteConfigs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { PublicSiteConfig } from "@src/site-configs";

let siteConfigs: PublicSiteConfig[];

export function getSiteConfigs() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please keep changes in a PR to a minimum and move this refactor (move of getSiteConfigs/getSiteConfigForDomain to it's own file) to an individual PR

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this refactor makes sense. Here's the rationale from the PR description:

getSiteConfigForDomain/getSiteConfigs moved into getSiteConfigs.ts so the NewsListBlock loader can build absolute URLs without pulling next/headers into the browser bundle. Existing call sites keep working via a re-export.

But process.env.PUBLIC_SITE_CONFIGS isn't available in the browser, why do we need to move it then?

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;
}
25 changes: 4 additions & 21 deletions demo/site/src/util/siteConfig.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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) {
Expand All @@ -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());
Expand Down
38 changes: 38 additions & 0 deletions demo/site/src/util/structuredData/buildArticle.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Comment on lines +11 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if we could pick the types from GQLNews here or use a fragment.

scope: ContentScope;
};

export function buildArticle({ news, scope }: BuildArticleOptions): WithContext<Article> {
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,
Comment on lines +34 to +35

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imho we should not include the same organisation object, that we already include on every page, here as author and publisher. I'd use auther/publisher ONLY if we would have real values (and we would store the author per news - and display them in the site)

mainEntityOfPage: detailUrl,
};
}
30 changes: 30 additions & 0 deletions demo/site/src/util/structuredData/buildNewsItemList.ts
Original file line number Diff line number Diff line change
@@ -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<ItemList> {
const siteUrl = getSiteConfigForDomain(scope.domain).url;
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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}` })}`,
})),
};
}
26 changes: 26 additions & 0 deletions demo/site/src/util/structuredData/buildOrganization.ts
Original file line number Diff line number Diff line change
@@ -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<Organization, string>;

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<Organization> {
return { "@context": "https://schema.org", ...buildOrganizationNode(siteConfig) };
}
23 changes: 23 additions & 0 deletions demo/site/src/util/structuredData/damImageToAbsoluteUrl.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Comment on lines +11 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This generates an image in its original size and aspect ratio. Google recommends to generate multiple images:

For best results, we recommend providing multiple high-resolution images (minimum of 50K pixels when multiplying width and height) with the following aspect ratios: 16x9, 4x3, and 1x1.
https://developers.google.com/search/docs/appearance/structured-data/article


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;
}
Loading