From e315fbcb537ac7b8cc8c993ced96bc85b543b9e7 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Fri, 11 Sep 2026 17:20:53 +0800 Subject: [PATCH] feat(www): emit sitemap lastmod from content dates (#50198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``. **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 ## 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. --- .github/workflows/www-tests.yml | 5 + apps/www/README.md | 4 + apps/www/app/blog/[slug]/page.tsx | 10 +- apps/www/generate-sitemap.test.ts | 312 ++++++++++++++++++++++++ apps/www/internals/generate-sitemap.mjs | 104 +++++--- apps/www/lib/frontmatter.mjs | 13 + apps/www/lib/json-ld.ts | 2 + apps/www/package.json | 1 + apps/www/types/post.ts | 2 + pnpm-lock.yaml | 3 + 10 files changed, 419 insertions(+), 37 deletions(-) create mode 100644 apps/www/generate-sitemap.test.ts create mode 100644 apps/www/lib/frontmatter.mjs diff --git a/.github/workflows/www-tests.yml b/.github/workflows/www-tests.yml index 3e9b8dc1dbb..281a6a30127 100644 --- a/.github/workflows/www-tests.yml +++ b/.github/workflows/www-tests.yml @@ -8,8 +8,13 @@ on: - 'apps/www/next.config.mjs' - 'apps/www/next.config.js' - 'apps/www/lib/**/*.js' + - 'apps/www/lib/**/*.mjs' - 'apps/www/content/md/**' - 'apps/www/scripts/**/*.mjs' + - 'apps/www/internals/**/*.mjs' + - 'apps/www/_blog/**' + - 'apps/www/_alternatives/**' + - 'apps/www/_customers/**' - 'apps/www/public/.well-known/**' # www catalog tests check that linked docs guides exist, so guide changes # must trigger these tests and their sources must be included in checkout. diff --git a/apps/www/README.md b/apps/www/README.md index 8695e930126..f7f66b9cc03 100644 --- a/apps/www/README.md +++ b/apps/www/README.md @@ -97,6 +97,10 @@ imgThumb: my-image.png --- ``` +#### Blog post dates + +Use quoted `YYYY-MM-DD` values. `date` is the publication date. Set optional `updated` for substantive content revisions, excluding typo, link, or image fixes. It must be on or after `date` and supplies the sitemap and structured-data modification date. When absent, `date` supplies both. + #### Events Events use different image fields to avoid confusion with their display patterns: diff --git a/apps/www/app/blog/[slug]/page.tsx b/apps/www/app/blog/[slug]/page.tsx index 510218781f9..b9d6177ca1f 100644 --- a/apps/www/app/blog/[slug]/page.tsx +++ b/apps/www/app/blog/[slug]/page.tsx @@ -11,6 +11,7 @@ import { } from '@/lib/blog-images' import { breadcrumbs } from '@/lib/breadcrumbs' import { SITE_ORIGIN } from '@/lib/constants' +import { parseFrontmatter } from '@/lib/frontmatter.mjs' import { blogPostingSchema, breadcrumbListSchema, serializeJsonLd } from '@/lib/json-ld' import { mdAlternates } from '@/lib/md-alternates' import { getAllPostSlugs, getPostdata, getSortedPosts } from '@/lib/posts' @@ -60,12 +61,10 @@ export async function generateMetadata({ params }: { params: Promise }): } } - const matter = (await import('gray-matter')).default - // Try to get static markdown post first try { const postContent = await getPostdata(slug, '_blog') - const parsedContent = matter(postContent) as unknown as MatterReturn + const parsedContent = parseFrontmatter(postContent) as unknown as MatterReturn const blogPost = parsedContent.data const metaImageUrl = getAbsoluteBlogSocialImage(blogPost, SITE_ORIGIN) @@ -103,11 +102,9 @@ export default async function BlogPostPage({ params }: { params: Promise const { isEnabled: isDraft } = await draftMode() - const matter = (await import('gray-matter')).default - try { const postContent = await getPostdata(slug, '_blog') - const parsedContent = matter(postContent) as unknown as MatterReturn + const parsedContent = parseFrontmatter(postContent) as unknown as MatterReturn const content = parsedContent.content const tocDepth = (parsedContent.data as any)?.toc_depth ?? 3 const { preprocessMdxWithCodeTabs } = await import('~/components/CodeTabs') @@ -167,6 +164,7 @@ export default async function BlogPostPage({ params }: { params: Promise description: frontmatter.description, image: imageUrl, datePublished: frontmatter.date, + dateModified: frontmatter.updated ?? frontmatter.date, authors: blogAuthors.length > 0 ? blogAuthors : [{ name: 'Supabase' }], }) const breadcrumbJsonLd = breadcrumbListSchema([ diff --git a/apps/www/generate-sitemap.test.ts b/apps/www/generate-sitemap.test.ts new file mode 100644 index 00000000000..52d39ab6186 --- /dev/null +++ b/apps/www/generate-sitemap.test.ts @@ -0,0 +1,312 @@ +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { parseFrontmatter } from './lib/frontmatter.mjs' +import { blogPostingSchema, serializeJsonLd } from './lib/json-ld' + +const GENERATOR = path.join(process.cwd(), 'internals', 'generate-sitemap.mjs') +const LEGACY_LINK = 'https://supabase.com/changelog/12345-legacy-entry' +const TIMED_LINK = 'https://supabase.com/changelog/23456-timed-entry' +const SPAWN_TIMEOUT_MS = 30_000 + +const createdDirs: string[] = [] + +type UrlEntry = { loc: string; lastmod?: string } + +function writeFixture(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sitemap-')) + createdDirs.push(dir) + fs.mkdirSync(path.join(dir, 'public'), { recursive: true }) + for (const [relativePath, content] of Object.entries(files)) { + const target = path.join(dir, relativePath) + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.writeFileSync(target, content) + } + return dir +} + +function runGenerator(fixtureDir: string) { + return spawnSync(process.execPath, [GENERATOR], { + cwd: fixtureDir, + encoding: 'utf-8', + env: { ...process.env, TZ: 'UTC' }, + timeout: SPAWN_TIMEOUT_MS, + }) +} + +function mdx(frontmatter: string): string { + return `---\n${frontmatter}\n---\n\n# Body\n` +} + +function blogFixture(slugWithDate: string, frontmatter: string): Record { + return { [`_blog/${slugWithDate}.mdx`]: mdx(frontmatter) } +} + +function rssItem(link: string, pubDate: string): string { + return [ + ' ', + ` ${link}`, + ' Entry', + ` ${link}`, + ` ${pubDate}`, + ' ', + ].join('\n') +} + +function rss(items: string[]): string { + return `\n\n${items.join('\n')}\n\n` +} + +function urlEntries(xml: string): UrlEntry[] { + return [...xml.matchAll(/([\s\S]*?)<\/url>/g)].map(([, block]) => ({ + loc: block.match(/\s*([^<\s]+)\s*<\/loc\s*>/)![1], + lastmod: block.match(/\s*([^<\s]+)\s*<\/lastmod\s*>/)?.[1], + })) +} + +afterAll(() => { + for (const dir of createdDirs) fs.rmSync(dir, { recursive: true, force: true }) +}) + +describe('generate-sitemap lastmod', () => { + let fixtureDir: string + let result: ReturnType + let sitemap = '' + let entries: UrlEntry[] = [] + + const entryFor = (loc: string) => entries.find((entry) => entry.loc === loc) + + beforeAll(() => { + fixtureDir = writeFixture({ + ...blogFixture('2026-01-05-published-only', "date: '2026-01-05'"), + ...blogFixture('2026-01-06-revised', "date: '2026-01-06'\nupdated: '2026-03-01'"), + ...blogFixture('2026-01-07-with-time', "date: '2026-01-07T09:30:00'"), + ...blogFixture('2026-01-08-unquoted-minutes', 'date: 2026-01-08T09:30'), + ...blogFixture('2026-01-09-unquoted-date', 'date: 2026-01-09'), + '_alternatives/supabase-vs-example.mdx': mdx("date: '2025-11-20'"), + '_customers/acme.mdx': mdx("date: '2024-05-16T08:00:00Z'"), + '_events/2026-02-01-webinar.mdx': mdx("date: '2026-02-01T19:00:00.000-07:00'"), + 'pages/company.tsx': '', + 'public/changelog-rss.xml': rss([ + rssItem(LEGACY_LINK, 'Tue, 03 Feb 2026 00:00:00 +0000'), + rssItem(TIMED_LINK, 'Wed, 04 Feb 2026 20:15:00 -0700'), + ]), + }) + result = runGenerator(fixtureDir) + const sitemapPath = path.join(fixtureDir, 'public', 'sitemap_www.xml') + if (fs.existsSync(sitemapPath)) { + sitemap = fs.readFileSync(sitemapPath, 'utf-8') + entries = urlEntries(sitemap) + } + }, SPAWN_TIMEOUT_MS) + + it('runs cleanly and writes sitemap_www.xml', () => { + expect(result.status, result.stderr).toBe(0) + expect(sitemap).not.toBe('') + }) + + it('uses the publish date when a post has no updated field', () => { + expect(entryFor('https://supabase.com/blog/published-only')?.lastmod).toBe('2026-01-05') + }) + + it('prefers updated over date', () => { + expect(entryFor('https://supabase.com/blog/revised')?.lastmod).toBe('2026-03-01') + }) + + it('drops the time part of a quoted datetime string', () => { + expect(entryFor('https://supabase.com/blog/with-time')?.lastmod).toBe('2026-01-07') + }) + + it('accepts an unquoted datetime without seconds, which YAML leaves as a string', () => { + expect(entryFor('https://supabase.com/blog/unquoted-minutes')?.lastmod).toBe('2026-01-08') + }) + + it('accepts an unquoted date-only value', () => { + expect(entryFor('https://supabase.com/blog/unquoted-date')?.lastmod).toBe('2026-01-09') + }) + + it('dates alternatives and customer stories from their frontmatter', () => { + expect(entryFor('https://supabase.com/alternatives/supabase-vs-example')?.lastmod).toBe( + '2025-11-20' + ) + expect(entryFor('https://supabase.com/customers/acme')?.lastmod).toBe('2024-05-16') + }) + + it('emits no lastmod for events, static pages, and the proxied evals app', () => { + for (const loc of [ + 'https://supabase.com/events/webinar', + 'https://supabase.com/company', + 'https://supabase.com/evals', + ]) { + const entry = entryFor(loc) + expect(entry, loc).toBeDefined() + expect(entry?.lastmod, loc).toBeUndefined() + } + }) + + it('dates legacy changelog entries from the RSS pubDate', () => { + expect(entryFor(LEGACY_LINK)?.lastmod).toBe('2026-02-03') + }) + + it('truncates a timed, offset pubDate to its UTC day', () => { + expect(entryFor(TIMED_LINK)?.lastmod).toBe('2026-02-05') + }) + + it('emits only day-precision lastmod values, one per dated source', () => { + const lastmods = entries.map((entry) => entry.lastmod).filter(Boolean) + expect(lastmods).toHaveLength(9) + for (const lastmod of lastmods) expect(lastmod).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + + it('leaves the sitemap index untouched', () => { + const index = fs.readFileSync(path.join(fixtureDir, 'public', 'sitemap.xml'), 'utf-8') + expect(index).toContain('https://supabase.com/sitemap_www.xml') + expect(index).toContain('https://supabase.com/docs/sitemap.xml') + }) +}) + +describe('frontmatter dates in sitemap and blog JSON-LD', () => { + it.each([ + ['2026-01-14T09:30:00', '2026-01-14'], + ['2026-01-15T16:00:00-08:00', '2026-01-15'], + ['2026-01-16T00:00:00Z', '2026-01-16'], + ['2026-01-17T00:30:00+14:00', '2026-01-17'], + ['2026-01-18', '2026-01-18'], + ])('preserves the authored day of %s regardless of quoting', (value, expectedDay) => { + const files = { + ...blogFixture('2026-01-01-quoted', `date: '2026-01-01'\nupdated: '${value}'`), + ...blogFixture('2026-01-01-unquoted', `date: 2026-01-01\nupdated: ${value} # revision`), + } + const dir = writeFixture(files) + const result = runGenerator(dir) + expect(result.status, result.stderr).toBe(0) + const entries = urlEntries(fs.readFileSync(path.join(dir, 'public/sitemap_www.xml'), 'utf-8')) + expect( + entries.filter((entry) => entry.loc.includes('/blog/')).map((entry) => entry.lastmod) + ).toEqual([expectedDay, expectedDay]) + + for (const content of Object.values(files)) { + const { data } = parseFrontmatter(content) + const schema = JSON.parse( + serializeJsonLd( + blogPostingSchema({ + url: 'https://supabase.com/blog/example', + headline: 'Example', + image: 'https://supabase.com/example.png', + datePublished: data.date, + dateModified: data.updated ?? data.date, + authors: [{ name: 'Supabase' }], + }) + ) + ) + expect(schema.datePublished).toBe('2026-01-01') + expect(schema.dateModified).toBe(value) + expect(schema.dateModified.slice(0, 10)).toBe(expectedDay) + } + }) + + it.each(['javascript', 'js', 'JavaScript'])( + 'rejects %s frontmatter without executing it', + (language) => { + const content = `---${language}\n(require('fs').writeFileSync('executed', 'yes'), {date: '2026-01-01'})\n---\n` + const dir = writeFixture({ '_blog/2026-01-01-script.mdx': content }) + const result = runGenerator(dir) + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('JavaScript frontmatter is not supported') + expect(fs.existsSync(path.join(dir, 'executed'))).toBe(false) + expect(() => parseFrontmatter(content)).toThrow('JavaScript frontmatter is not supported') + } + ) +}) + +describe('generate-sitemap rejects dates it cannot trust', () => { + const cases: Array<{ name: string; files: Record; stderrIncludes: string[] }> = [ + { + name: 'a non-date word', + files: blogFixture('2026-01-10-bad-word', "date: '2026-01-10'\nupdated: 'soon'"), + stderrIncludes: ['_blog/2026-01-10-bad-word.mdx', '"soon"'], + }, + { + name: 'an updated value earlier than the publish date', + files: blogFixture('2026-01-17-backdated', "date: '2026-01-17'\nupdated: '2025-12-31'"), + stderrIncludes: ['_blog/2026-01-17-backdated.mdx', 'earlier than date'], + }, + { + name: 'trailing text after a valid day', + files: blogFixture('2026-01-11-bad-suffix', "date: '2026-01-11'\nupdated: '2026-01-11soon'"), + stderrIncludes: ['_blog/2026-01-11-bad-suffix.mdx', '"2026-01-11soon"'], + }, + { + name: 'a day that does not exist', + files: blogFixture('2026-01-12-bad-day', "date: '2026-01-12'\nupdated: '2026-02-30'"), + stderrIncludes: ['_blog/2026-01-12-bad-day.mdx', '"2026-02-30"'], + }, + { + name: 'a month that does not exist', + files: blogFixture('2026-01-13-bad-month', "date: '2026-01-13'\nupdated: '2026-13-45'"), + stderrIncludes: ['_blog/2026-01-13-bad-month.mdx', '"2026-13-45"'], + }, + { + name: 'a time of day that does not exist', + files: blogFixture('2026-01-18-bad-time', "date: '2026-01-18'\nupdated: '2026-01-18T99:99'"), + stderrIncludes: ['_blog/2026-01-18-bad-time.mdx', '"2026-01-18T99:99"'], + }, + { + name: 'an offset that does not exist', + files: blogFixture( + '2026-01-19-bad-offset', + "date: '2026-01-19'\nupdated: '2026-01-19T09:30:00+99:00'" + ), + stderrIncludes: ['_blog/2026-01-19-bad-offset.mdx', '"2026-01-19T09:30:00+99:00"'], + }, + { + name: 'an unparseable changelog pubDate', + files: { 'public/changelog-rss.xml': rss([rssItem(LEGACY_LINK, 'Invalid Date +0000')]) }, + stderrIncludes: [`changelog-rss ${LEGACY_LINK}`, '"Invalid Date +0000"'], + }, + ] + + for (const testCase of cases) { + it( + `fails the build on ${testCase.name}`, + () => { + const result = runGenerator(writeFixture(testCase.files)) + expect(result.status).not.toBe(0) + for (const fragment of testCase.stderrIncludes) { + expect(result.stderr).toContain(fragment) + } + }, + SPAWN_TIMEOUT_MS + ) + } +}) + +describe('generate-sitemap against the repo content', () => { + const wwwRoot = process.cwd() + const mdxCount = (dir: string) => + fs.readdirSync(path.join(wwwRoot, dir)).filter((name) => name.endsWith('.mdx')).length + + it( + 'dates every blog, alternatives, and customers file without throwing', + () => { + const result = runGenerator(wwwRoot) + expect(result.status, result.stderr).toBe(0) + const entries = urlEntries( + fs.readFileSync(path.join(wwwRoot, 'public', 'sitemap_www.xml'), 'utf-8') + ) + const datedContent = entries.filter( + (entry) => entry.lastmod && !entry.loc.includes('/changelog/') + ) + expect(datedContent).toHaveLength( + mdxCount('_blog') + mdxCount('_alternatives') + mdxCount('_customers') + ) + expect( + entries.filter((entry) => entry.loc.includes('/events/') && entry.lastmod) + ).toHaveLength(0) + }, + SPAWN_TIMEOUT_MS + ) +}) diff --git a/apps/www/internals/generate-sitemap.mjs b/apps/www/internals/generate-sitemap.mjs index 939c6be54d1..60f7ecba619 100644 --- a/apps/www/internals/generate-sitemap.mjs +++ b/apps/www/internals/generate-sitemap.mjs @@ -2,6 +2,61 @@ import { readFileSync, writeFileSync } from 'fs' import { globby } from 'globby' import prettier from 'prettier' +import { parseFrontmatter } from '../lib/frontmatter.mjs' + +const DATED_COLLECTIONS = ['_blog/', '_alternatives/', '_customers/'] +const ISO_DATE_SHAPE = + /^(\d{4}-\d{2}-\d{2})(?:T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)?)?$/ +const RSS_PUB_DATE_SHAPE = + /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} (?:[+-]\d{4}|GMT|UTC)$/ + +function lastmodError(source, value, hint = '') { + return new Error( + `${source}: cannot derive lastmod from date value ${JSON.stringify(value)}${hint}` + ) +} + +function toIsoDate(value, source) { + if (typeof value !== 'string') throw lastmodError(source, value) + const match = ISO_DATE_SHAPE.exec(value) + if (!match) throw lastmodError(source, value) + const candidate = match[1] + const roundTrip = new Date(`${candidate}T00:00:00Z`) + if (Number.isNaN(roundTrip.getTime()) || roundTrip.toISOString().slice(0, 10) !== candidate) { + throw lastmodError(source, value, '; not a real calendar day') + } + return candidate +} + +function contentLastmod(filePath) { + const { data } = parseFrontmatter(readFileSync(filePath, 'utf-8')) + const published = data.date == null ? undefined : toIsoDate(data.date, filePath) + const updated = data.updated == null ? undefined : toIsoDate(data.updated, filePath) + if (published && updated && updated < published) { + throw lastmodError(filePath, data.updated, '; updated is earlier than date') + } + return updated ?? published +} + +function changelogLastmod(pubDate, link) { + const source = `changelog-rss ${link}` + if (!RSS_PUB_DATE_SHAPE.test(pubDate)) throw lastmodError(source, pubDate) + const instant = new Date(pubDate) + if (Number.isNaN(instant.getTime())) throw lastmodError(source, pubDate) + return instant.toISOString().slice(0, 10) +} + +function urlEntry(loc, lastmod) { + return ` + + ${loc} + ${lastmod ? `${lastmod}` : ''} + weekly + 0.5 + + ` +} + async function generate() { const prettierConfig = await prettier.resolveConfig('./.prettierrc.js') @@ -106,50 +161,37 @@ async function generate() { route = `/${eventsUrl}/` + substring } - return ` - - ${`https://supabase.com${route}`} - weekly - 0.5 - - ` + const lastmod = DATED_COLLECTIONS.some((prefix) => page.startsWith(prefix)) + ? contentLastmod(page) + : undefined + + return urlEntry(`https://supabase.com${route}`, lastmod) }) .filter(Boolean) // Changelog detail pages are dynamic routes; include them from generated changelog RSS links. const changelogDetailUrls = (() => { + let rss try { - const rss = readFileSync('public/changelog-rss.xml', 'utf-8') - const matches = [ - ...rss.matchAll(/(https:\/\/supabase\.com\/changelog\/\d+[^<]*)<\/link>/g), - ] - const uniqueUrls = [...new Set(matches.map((match) => match[1]))] - - return uniqueUrls.map( - (url) => ` - - ${url} - weekly - 0.5 - - ` - ) + rss = readFileSync('public/changelog-rss.xml', 'utf-8') } catch { return [] } + + const lastmodByUrl = new Map() + for (const [, item] of rss.matchAll(/([\s\S]*?)<\/item>/g)) { + const link = item.match(/(https:\/\/supabase\.com\/changelog\/\d+[^<]*)<\/link>/)?.[1] + if (!link || lastmodByUrl.has(link)) continue + const pubDate = item.match(/([^<]*)<\/pubDate>/)?.[1] + lastmodByUrl.set(link, pubDate ? changelogLastmod(pubDate, link) : undefined) + } + + return [...lastmodByUrl].map(([url, lastmod]) => urlEntry(url, lastmod)) })() // /evals is a separate app proxied onto supabase.com via a rewrite in lib/rewrites.js, // so it has no page file for the globs above to find. Hardcode it here. - const proxiedAppUrls = [ - ` - - https://supabase.com/evals - weekly - 0.5 - - `, - ] + const proxiedAppUrls = [urlEntry('https://supabase.com/evals')] const sitemap = ` diff --git a/apps/www/lib/frontmatter.mjs b/apps/www/lib/frontmatter.mjs new file mode 100644 index 00000000000..ad070f0ec4d --- /dev/null +++ b/apps/www/lib/frontmatter.mjs @@ -0,0 +1,13 @@ +import matter from 'gray-matter' +import yaml from 'js-yaml' + +export function parseFrontmatter(content) { + return matter(content, { + schema: yaml.JSON_SCHEMA, + engines: { + javascript: () => { + throw new Error('JavaScript frontmatter is not supported') + }, + }, + }) +} diff --git a/apps/www/lib/json-ld.ts b/apps/www/lib/json-ld.ts index 6e0e46a6b9d..5cd493e7a4d 100644 --- a/apps/www/lib/json-ld.ts +++ b/apps/www/lib/json-ld.ts @@ -152,6 +152,7 @@ interface BlogPostingSchemaInput { description?: string image: string datePublished: string + dateModified: string authors: Array<{ name: string; url?: string }> } @@ -167,6 +168,7 @@ export function blogPostingSchema(input: BlogPostingSchemaInput) { description: input.description, image: input.image, datePublished: input.datePublished, + dateModified: input.dateModified, author: input.authors.map((a) => ({ '@type': 'Person', name: a.name, diff --git a/apps/www/package.json b/apps/www/package.json index 67b687abfb9..d2b5bfb2909 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -57,6 +57,7 @@ "gray-matter": "^4.0.3", "gsap": "^3.13.0", "icons": "workspace:*", + "js-yaml": "^3.15.1", "lucide-react": "*", "markdown-toc": "^1.2.0", "marketing": "workspace:*", diff --git a/apps/www/types/post.ts b/apps/www/types/post.ts index fcd78a7276b..430bdc0602e 100644 --- a/apps/www/types/post.ts +++ b/apps/www/types/post.ts @@ -8,6 +8,7 @@ export interface PostTypes { title: string name?: string date: string + updated?: string formattedDate?: string coverImage?: string author?: string @@ -118,6 +119,7 @@ export type BlogData = { author?: string authors?: StaticAuthor[] date: string + updated?: string categories?: string[] tags?: | string[] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ec8de8a514..e4886869599 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1841,6 +1841,9 @@ importers: icons: specifier: workspace:* version: link:../../packages/icons + js-yaml: + specifier: ^3.15.1 + version: 3.15.1 lucide-react: specifier: '*' version: 0.436.0(react@19.2.6)