Skip to content
Merged
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
60 changes: 60 additions & 0 deletions website/scripts/validate-structured-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ async function validatePage(pathname) {
);
}

validateProducts(nodes, pathname);

checked.push(pathname);
} catch (error) {
errors.push(
Expand All @@ -116,6 +118,64 @@ async function validatePage(pathname) {
}
}

function validateProducts(nodes, pathname) {
const nodesById = new Map(
nodes
.filter((node) => typeof node?.["@id"] === "string")
.map((node) => [node["@id"], node]),
);

for (const product of nodes.filter((node) => node?.["@type"] === "Product")) {
if (
!product.brand ||
!["Brand", "Organization"].includes(product.brand["@type"])
) {
errors.push(
`${pathname}: Product.brand must be an object with @type "Brand" or "Organization"`,
);
}

const offers = Array.isArray(product.offers)
? product.offers
: product.offers
? [product.offers]
: [];

if (offers.length === 0 && !product.aggregateRating && !product.review) {
errors.push(
`${pathname}: Product requires an offer, aggregate rating, or review`,
);
}

for (const offerReference of offers) {
const offer =
typeof offerReference?.["@id"] === "string"
? nodesById.get(offerReference["@id"])
: offerReference;
if (!offer) {
errors.push(`${pathname}: Product references an unknown Offer`);
continue;
}

const specification = offer.priceSpecification;
const price = offer.price ?? specification?.price;
const currency = offer.priceCurrency ?? specification?.priceCurrency;
if (price === undefined) {
errors.push(`${pathname}: Product Offer is missing a price`);
}
if (!currency) {
errors.push(`${pathname}: Product Offer is missing a price currency`);
}
if (
specification?.["@type"] === "UnitPriceSpecification" &&
specification.price === undefined
) {
errors.push(`${pathname}: UnitPriceSpecification is missing its price`);
}
Comment thread
PascalSenn marked this conversation as resolved.
}
}
}

async function validateExcludedRoute(pathname) {
const response = await fetch(new URL(pathname, baseUrl));
const html = await response.text();
Expand Down
70 changes: 34 additions & 36 deletions website/src/helpers/nitroStructuredData.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TIERS } from "@/src/components/pricing/pricingData";
import { SITE_NAME } from "@/src/helpers/site";
import { toAbsoluteUrl } from "@/src/helpers/siteUrl";
import {
ORGANIZATION_ID,
Expand All @@ -11,42 +12,35 @@ export const NITRO_PRODUCT_ID = schemaId("/products/nitro", "product");
export const NITRO_OFFER_CATALOG_ID = schemaId("/pricing", "offers");

export function createNitroOfferNodes(): readonly JsonLdNode[] {
return TIERS.map((tier) => {
const exactPrice = tier.monthlyPrice;
const minimumPrice = tier.minimumMonthlyPrice;
return TIERS.flatMap((tier) => {
const price = tier.monthlyPrice ?? tier.minimumMonthlyPrice;

return {
"@type": "Offer",
"@id": schemaId("/pricing", `offer-${tier.id}`),
name: `Nitro ${tier.name}`,
description: tier.tagline,
url: toAbsoluteUrl(tier.ctaHref),
availability: "https://schema.org/InStock",
seller: schemaRef(ORGANIZATION_ID),
itemOffered: schemaRef(NITRO_PRODUCT_ID),
...(exactPrice !== undefined
? {
price: exactPrice,
priceCurrency: "USD",
priceSpecification: {
"@type": "UnitPriceSpecification",
price: exactPrice,
priceCurrency: "USD",
unitText: "MONTH",
},
}
: {}),
...(minimumPrice !== undefined
? {
priceSpecification: {
"@type": "UnitPriceSpecification",
minPrice: minimumPrice,
priceCurrency: "USD",
unitText: "MONTH",
},
}
: {}),
};
// A custom quote is not a price. Keep that option visible on the pricing
// page, but do not publish an incomplete Google Product offer for it.
if (price === undefined) {
return [];
}

return [
{
"@type": "Offer",
"@id": schemaId("/pricing", `offer-${tier.id}`),
name: `Nitro ${tier.name}`,
description: tier.tagline,
url: toAbsoluteUrl(tier.ctaHref),
availability: "https://schema.org/InStock",
seller: schemaRef(ORGANIZATION_ID),
itemOffered: schemaRef(NITRO_PRODUCT_ID),
price,
priceCurrency: "USD",
priceSpecification: {
"@type": "UnitPriceSpecification",
price,
priceCurrency: "USD",
unitText: "MONTH",
},
Comment thread
PascalSenn marked this conversation as resolved.
},
];
});
}

Expand All @@ -62,7 +56,11 @@ export function createNitroProductNode(
url: toAbsoluteUrl("/products/nitro"),
image: toAbsoluteUrl("/images/nitro/nitro-app.png"),
category: "GraphQL observability and API operations platform",
brand: schemaRef(ORGANIZATION_ID),
brand: {
"@type": "Brand",
name: SITE_NAME,
url: toAbsoluteUrl("/"),
},
manufacturer: schemaRef(ORGANIZATION_ID),
offers: offers.map((offer) => schemaRef(offer["@id"]!)),
};
Expand Down
Loading