From 2092513b04c736cb3af3fb25b2206cbc1f66e3a9 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Fri, 11 Sep 2026 16:28:28 +0800 Subject: [PATCH] refactor(www): simplify sitemap date parsing --- .github/workflows/www-tests.yml | 1 + apps/www/README.md | 4 +- apps/www/app/blog/[slug]/page.tsx | 9 +-- apps/www/generate-sitemap.test.ts | 83 +++++++++++++++++-------- apps/www/internals/generate-sitemap.mjs | 37 ++++------- apps/www/lib/frontmatter.mjs | 13 ++++ apps/www/package.json | 1 + pnpm-lock.yaml | 3 + 8 files changed, 91 insertions(+), 60 deletions(-) create mode 100644 apps/www/lib/frontmatter.mjs diff --git a/.github/workflows/www-tests.yml b/.github/workflows/www-tests.yml index 3447279803b..1c80cc20a22 100644 --- a/.github/workflows/www-tests.yml +++ b/.github/workflows/www-tests.yml @@ -8,6 +8,7 @@ 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' diff --git a/apps/www/README.md b/apps/www/README.md index 65a7b8ca364..f7f66b9cc03 100644 --- a/apps/www/README.md +++ b/apps/www/README.md @@ -99,9 +99,7 @@ imgThumb: my-image.png #### Blog post dates -- `date` (required, `YYYY-MM-DD`): publish date. Feeds the blog index order, JSON-LD `datePublished`, and the sitemap `` when `updated` is absent. -- `updated` (optional, `YYYY-MM-DD`): set it when a post gets a substantive revision (new sections, corrected claims, changed recommendations). Leave it absent for typo, link, and image fixes. It becomes the sitemap `` and JSON-LD `dateModified`; a stale or inflated value is worse than none because search engines only trust lastmod they can verify against the page. -- Quote both values (`'2026-08-24'`). An unquoted date-only value is accepted as written; an unquoted value with a time or zone is rejected at build time, because YAML converts it into an absolute instant and the authored day can no longer be recovered. `updated` must not be earlier than `date`. +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 diff --git a/apps/www/app/blog/[slug]/page.tsx b/apps/www/app/blog/[slug]/page.tsx index b86e9c95cd8..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') diff --git a/apps/www/generate-sitemap.test.ts b/apps/www/generate-sitemap.test.ts index fa625231c83..c0a8789f410 100644 --- a/apps/www/generate-sitemap.test.ts +++ b/apps/www/generate-sitemap.test.ts @@ -4,6 +4,9 @@ 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' @@ -121,7 +124,7 @@ describe('generate-sitemap lastmod', () => { expect(entryFor('https://supabase.com/blog/unquoted-minutes')?.lastmod).toBe('2026-01-08') }) - it('accepts an unquoted date-only value, which YAML parses into a Date', () => { + it('accepts an unquoted date-only value', () => { expect(entryFor('https://supabase.com/blog/unquoted-date')?.lastmod).toBe('2026-01-09') }) @@ -165,6 +168,60 @@ describe('generate-sitemap lastmod', () => { }) }) +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[] }> = [ { @@ -172,22 +229,6 @@ describe('generate-sitemap rejects dates it cannot trust', () => { files: blogFixture('2026-01-10-bad-word', "date: '2026-01-10'\nupdated: 'soon'"), stderrIncludes: ['_blog/2026-01-10-bad-word.mdx', '"soon"'], }, - { - name: 'an unquoted offset value that lands on UTC midnight', - files: blogFixture( - '2026-01-15-unquoted-offset', - "date: '2026-01-15'\nupdated: 2026-01-15T16:00:00-08:00" - ), - stderrIncludes: ['_blog/2026-01-15-unquoted-offset.mdx', 'quote it'], - }, - { - name: 'an unquoted zoned midnight value', - files: blogFixture( - '2026-01-16-unquoted-zulu', - "date: '2026-01-16'\nupdated: 2026-01-16T00:00:00Z" - ), - stderrIncludes: ['_blog/2026-01-16-unquoted-zulu.mdx', 'quote it'], - }, { name: 'an updated value earlier than the publish date', files: blogFixture('2026-01-17-backdated', "date: '2026-01-17'\nupdated: '2025-12-31'"), @@ -208,14 +249,6 @@ describe('generate-sitemap rejects dates it cannot trust', () => { 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: 'an unquoted value carrying a time part', - files: blogFixture( - '2026-01-14-unquoted-time', - "date: '2026-01-14'\nupdated: 2026-01-14T09:30:00" - ), - stderrIncludes: ['_blog/2026-01-14-unquoted-time.mdx', 'quote it'], - }, { name: 'an unparseable changelog pubDate', files: { 'public/changelog-rss.xml': rss([rssItem(LEGACY_LINK, 'Invalid Date +0000')]) }, diff --git a/apps/www/internals/generate-sitemap.mjs b/apps/www/internals/generate-sitemap.mjs index a9545aea936..0719c21ddf1 100644 --- a/apps/www/internals/generate-sitemap.mjs +++ b/apps/www/internals/generate-sitemap.mjs @@ -1,8 +1,9 @@ import { readFileSync, writeFileSync } from 'fs' import { globby } from 'globby' -import matter from 'gray-matter' 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\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/ @@ -10,9 +11,9 @@ 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 = '') { - const isValidDate = value instanceof Date && !Number.isNaN(value.getTime()) - const shown = isValidDate ? value.toISOString() : JSON.stringify(value) - return new Error(`${source}: cannot derive lastmod from date value ${shown}${hint}`) + return new Error( + `${source}: cannot derive lastmod from date value ${JSON.stringify(value)}${hint}` + ) } function toIsoDate(value, source) { @@ -27,30 +28,14 @@ function toIsoDate(value, source) { return candidate } -function authoredDay(file, key, source) { - const value = file.data[key] - if (!(value instanceof Date)) return toIsoDate(value, source) - const unquotedDateOnly = new RegExp(`^${key}:[ \\t]*(\\d{4}-\\d{2}-\\d{2})[ \\t]*$`, 'm').exec( - file.matter - ) - if (!unquotedDateOnly) throw lastmodError(source, value, "; quote it as a date-only 'YYYY-MM-DD'") - return toIsoDate(unquotedDateOnly[1], source) -} - -function isSet(value) { - return value !== undefined && value !== null -} - function contentLastmod(filePath) { - const file = matter(readFileSync(filePath, 'utf-8')) - if (!isSet(file.data.updated)) { - return isSet(file.data.date) ? authoredDay(file, 'date', filePath) : undefined + 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') } - const updated = authoredDay(file, 'updated', filePath) - if (isSet(file.data.date) && updated < authoredDay(file, 'date', filePath)) { - throw lastmodError(filePath, file.data.updated, '; updated is earlier than date') - } - return updated + return updated ?? published } function changelogLastmod(pubDate, link) { 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/package.json b/apps/www/package.json index bdc93fa0681..25bfc47f194 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/pnpm-lock.yaml b/pnpm-lock.yaml index 12f82101721..9078f70f3a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1823,6 +1823,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)