Files
supabase/apps/www/lib/json-ld.ts
Pamela Chia e315fbcb53 feat(www): emit sitemap lastmod from content dates (#50198)
I added content dates to `sitemap_www.xml` and `dateModified` to blog
JSON-LD so crawlers can compare freshness with page metadata. Both use
`updated` when present, otherwise the publication date.

**Changed:**
- **Consistent dates:** I use the same frontmatter parser for the blog
page and sitemap. It preserves authored dates across quoting and
timezones and rejects JavaScript frontmatter.
- **Invalid dates stop the build:** I reject impossible calendar days,
out-of-range times and offsets, malformed dates, and `updated` before
publication. Content changes run the generator in CI.
- **Authoring:** I documented optional `updated` for substantive
revisions. Events, static pages, and `/evals` omit `<lastmod>`.

**Note:** Changelog dates come from RSS. Existing sitemap omissions for
nonnumeric changelog slugs (GROWTH-1212) and app-router pages
(GROWTH-1214) remain separate.

## To test

On the preview:
- [x] Open `/sitemap_www.xml`: blog, alternatives, customer stories, and
included changelog entries should carry `YYYY-MM-DD` lastmod values.
Verified on the 2092513 preview: all 425 blog, 3 alternatives, 43
customer story, and 207 changelog entries carry a `YYYY-MM-DD` lastmod,
zero malformed values. Production currently emits no lastmod at all.
- [x] Inspect `/blog/supabase-is-now-available-in-gemini-enterprise`:
BlogPosting `dateModified` should be `2026-09-09`, matching its sitemap
entry. Verified: one BlogPosting block, `datePublished` and
`dateModified` both `2026-09-09`, sitemap lastmod `2026-09-09`.
- [x] Find the `/company` and event entries in the sitemap: neither
should carry lastmod. Verified: `/company` and all 13 `/events/` entries
have no lastmod.
- [x] Added: `/evals` and the `/changelog` index carry no lastmod
either.
- [x] Added: the blog page renders with no new console errors. The only
console error is a `/docs?_rsc=` prefetch 404: the preview host serves
404 for `/docs` itself, unrelated to this change.

## Linear
- fixes GROWTH-1206


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Blog posts now support optional updated dates for substantive
revisions.
- Sitemap entries include accurate modification dates for blog,
alternatives, customers, and changelog content.
  - Blog structured data now includes the post’s modification date.

- **Bug Fixes**
- Improved validation prevents invalid or inconsistent content dates
from generating incorrect sitemap data.
- Changelog sitemap links are deduplicated and assigned their published
dates.

- **Documentation**
- Added guidance for specifying publication and update dates in blog
post metadata.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-11 17:20:53 +08:00

188 lines
4.6 KiB
TypeScript

import { DEFAULT_META_DESCRIPTION, SITE_NAME } from './constants'
const CANONICAL_ORIGIN = 'https://supabase.com'
const ORG_ID = `${CANONICAL_ORIGIN}/#organization`
const SITE_ID = `${CANONICAL_ORIGIN}/#website`
const ORG_SAMEAS = [
'https://github.com/supabase',
'https://twitter.com/supabase',
'https://www.linkedin.com/company/supabase',
'https://youtube.com/c/supabase',
'https://discord.supabase.com/',
'https://www.tiktok.com/@supabase.com',
'https://www.instagram.com/supabasecom',
]
const ORG_LOGO_URL = `${CANONICAL_ORIGIN}/images/og/supabase-og.png`
type JsonLdSchema = Record<string, unknown> | Record<string, unknown>[]
export function serializeJsonLd(schema: JsonLdSchema): string {
return JSON.stringify(schema)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
}
interface OrganizationSchemaInput {
description?: string
}
export function organizationSchema(input: OrganizationSchemaInput = {}) {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': ORG_ID,
name: SITE_NAME,
url: CANONICAL_ORIGIN,
logo: {
'@type': 'ImageObject',
url: ORG_LOGO_URL,
},
description: input.description ?? DEFAULT_META_DESCRIPTION,
legalName: 'Supabase Pte. Ltd.',
contactPoint: {
'@type': 'ContactPoint',
contactType: 'customer support',
url: `${CANONICAL_ORIGIN}/support`,
},
address: {
'@type': 'PostalAddress',
streetAddress: '65 Chulia Street #38-02/03, OCBC Centre',
addressLocality: 'Singapore',
postalCode: '049513',
addressCountry: 'SG',
},
sameAs: ORG_SAMEAS,
}
}
interface WebSiteSchemaInput {
description?: string
}
export function websiteSchema(input: WebSiteSchemaInput = {}) {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': SITE_ID,
url: CANONICAL_ORIGIN,
name: SITE_NAME,
description: input.description ?? DEFAULT_META_DESCRIPTION,
publisher: { '@id': ORG_ID },
}
}
interface SoftwareApplicationSchemaInput {
name: string
description: string
url: string
image: string
applicationCategory?: string
}
export function softwareApplicationSchema(input: SoftwareApplicationSchemaInput) {
return {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: input.name,
description: input.description,
url: input.url,
image: input.image,
applicationCategory: input.applicationCategory ?? 'DeveloperApplication',
operatingSystem: 'Cross-platform',
publisher: { '@id': ORG_ID },
}
}
interface ServiceSchemaInput {
name: string
description: string
url: string
serviceType: string
offerings: Array<{ name: string; url: string }>
}
export function serviceSchema(input: ServiceSchemaInput) {
return {
'@context': 'https://schema.org',
'@type': 'Service',
name: input.name,
description: input.description,
url: input.url,
serviceType: input.serviceType,
provider: { '@id': ORG_ID },
hasOfferCatalog: {
'@type': 'OfferCatalog',
name: `${input.name} products`,
itemListElement: input.offerings.map((offering) => ({
'@type': 'Offer',
itemOffered: {
'@type': 'Service',
name: offering.name,
url: offering.url,
},
})),
},
}
}
export interface BreadcrumbItem {
name: string
url: string
}
export function breadcrumbListSchema(items: BreadcrumbItem[]) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url,
})),
}
}
interface BlogPostingSchemaInput {
url: string
headline: string
description?: string
image: string
datePublished: string
dateModified: string
authors: Array<{ name: string; url?: string }>
}
export function blogPostingSchema(input: BlogPostingSchemaInput) {
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
mainEntityOfPage: {
'@type': 'WebPage',
'@id': input.url,
},
headline: input.headline,
description: input.description,
image: input.image,
datePublished: input.datePublished,
dateModified: input.dateModified,
author: input.authors.map((a) => ({
'@type': 'Person',
name: a.name,
...(a.url ? { url: a.url } : {}),
})),
publisher: {
'@type': 'Organization',
'@id': ORG_ID,
name: SITE_NAME,
logo: {
'@type': 'ImageObject',
url: ORG_LOGO_URL,
},
},
}
}