refactor(www): simplify sitemap date parsing

This commit is contained in:
Pamela Chia committed 2026-09-11 16:28:28 +08:00
1 parent ec3ac396df
commit 2092513b04
8 files changed
+91 -60

No files matched your search

+1
View File
@@ -8,6 +8,7 @@ on:
- 'apps/www/next.config.mjs' - 'apps/www/next.config.mjs'
- 'apps/www/next.config.js' - 'apps/www/next.config.js'
- 'apps/www/lib/**/*.js' - 'apps/www/lib/**/*.js'
- 'apps/www/lib/**/*.mjs'
- 'apps/www/content/md/**' - 'apps/www/content/md/**'
- 'apps/www/scripts/**/*.mjs' - 'apps/www/scripts/**/*.mjs'
- 'apps/www/internals/**/*.mjs' - 'apps/www/internals/**/*.mjs'
+1 -3
View File
@@ -99,9 +99,7 @@ imgThumb: my-image.png
#### Blog post dates #### Blog post dates
- `date` (required, `YYYY-MM-DD`): publish date. Feeds the blog index order, JSON-LD `datePublished`, and the sitemap `<lastmod>` when `updated` is absent. 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.
- `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 `<lastmod>` 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`.
#### Events #### Events
+3 -6
View File
@@ -11,6 +11,7 @@ import {
} from '@/lib/blog-images' } from '@/lib/blog-images'
import { breadcrumbs } from '@/lib/breadcrumbs' import { breadcrumbs } from '@/lib/breadcrumbs'
import { SITE_ORIGIN } from '@/lib/constants' import { SITE_ORIGIN } from '@/lib/constants'
import { parseFrontmatter } from '@/lib/frontmatter.mjs'
import { blogPostingSchema, breadcrumbListSchema, serializeJsonLd } from '@/lib/json-ld' import { blogPostingSchema, breadcrumbListSchema, serializeJsonLd } from '@/lib/json-ld'
import { mdAlternates } from '@/lib/md-alternates' import { mdAlternates } from '@/lib/md-alternates'
import { getAllPostSlugs, getPostdata, getSortedPosts } from '@/lib/posts' import { getAllPostSlugs, getPostdata, getSortedPosts } from '@/lib/posts'
@@ -60,12 +61,10 @@ export async function generateMetadata({ params }: { params: Promise<Params> }):
} }
} }
const matter = (await import('gray-matter')).default
// Try to get static markdown post first // Try to get static markdown post first
try { try {
const postContent = await getPostdata(slug, '_blog') 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 blogPost = parsedContent.data
const metaImageUrl = getAbsoluteBlogSocialImage(blogPost, SITE_ORIGIN) const metaImageUrl = getAbsoluteBlogSocialImage(blogPost, SITE_ORIGIN)
@@ -103,11 +102,9 @@ export default async function BlogPostPage({ params }: { params: Promise<Params>
const { isEnabled: isDraft } = await draftMode() const { isEnabled: isDraft } = await draftMode()
const matter = (await import('gray-matter')).default
try { try {
const postContent = await getPostdata(slug, '_blog') 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 content = parsedContent.content
const tocDepth = (parsedContent.data as any)?.toc_depth ?? 3 const tocDepth = (parsedContent.data as any)?.toc_depth ?? 3
const { preprocessMdxWithCodeTabs } = await import('~/components/CodeTabs') const { preprocessMdxWithCodeTabs } = await import('~/components/CodeTabs')
+58 -25
View File
@@ -4,6 +4,9 @@ import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest' 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 GENERATOR = path.join(process.cwd(), 'internals', 'generate-sitemap.mjs')
const LEGACY_LINK = 'https://supabase.com/changelog/12345-legacy-entry' const LEGACY_LINK = 'https://supabase.com/changelog/12345-legacy-entry'
const TIMED_LINK = 'https://supabase.com/changelog/23456-timed-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') 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') 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', () => { describe('generate-sitemap rejects dates it cannot trust', () => {
const cases: Array<{ name: string; files: Record<string, string>; stderrIncludes: string[] }> = [ const cases: Array<{ name: string; files: Record<string, string>; 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'"), files: blogFixture('2026-01-10-bad-word', "date: '2026-01-10'\nupdated: 'soon'"),
stderrIncludes: ['_blog/2026-01-10-bad-word.mdx', '"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', name: 'an updated value earlier than the publish date',
files: blogFixture('2026-01-17-backdated', "date: '2026-01-17'\nupdated: '2025-12-31'"), 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'"), 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"'], 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', name: 'an unparseable changelog pubDate',
files: { 'public/changelog-rss.xml': rss([rssItem(LEGACY_LINK, 'Invalid Date +0000')]) }, files: { 'public/changelog-rss.xml': rss([rssItem(LEGACY_LINK, 'Invalid Date +0000')]) },
+11 -26
View File
@@ -1,8 +1,9 @@
import { readFileSync, writeFileSync } from 'fs' import { readFileSync, writeFileSync } from 'fs'
import { globby } from 'globby' import { globby } from 'globby'
import matter from 'gray-matter'
import prettier from 'prettier' import prettier from 'prettier'
import { parseFrontmatter } from '../lib/frontmatter.mjs'
const DATED_COLLECTIONS = ['_blog/', '_alternatives/', '_customers/'] const DATED_COLLECTIONS = ['_blog/', '_alternatives/', '_customers/']
const ISO_DATE_SHAPE = const ISO_DATE_SHAPE =
/^(\d{4}-\d{2}-\d{2})(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/ /^(\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)$/ /^[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 = '') { function lastmodError(source, value, hint = '') {
const isValidDate = value instanceof Date && !Number.isNaN(value.getTime()) return new Error(
const shown = isValidDate ? value.toISOString() : JSON.stringify(value) `${source}: cannot derive lastmod from date value ${JSON.stringify(value)}${hint}`
return new Error(`${source}: cannot derive lastmod from date value ${shown}${hint}`) )
} }
function toIsoDate(value, source) { function toIsoDate(value, source) {
@@ -27,30 +28,14 @@ function toIsoDate(value, source) {
return candidate 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) { function contentLastmod(filePath) {
const file = matter(readFileSync(filePath, 'utf-8')) const { data } = parseFrontmatter(readFileSync(filePath, 'utf-8'))
if (!isSet(file.data.updated)) { const published = data.date == null ? undefined : toIsoDate(data.date, filePath)
return isSet(file.data.date) ? authoredDay(file, 'date', filePath) : undefined 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) return updated ?? published
if (isSet(file.data.date) && updated < authoredDay(file, 'date', filePath)) {
throw lastmodError(filePath, file.data.updated, '; updated is earlier than date')
}
return updated
} }
function changelogLastmod(pubDate, link) { function changelogLastmod(pubDate, link) {
+13
View File
@@ -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')
},
},
})
}
+1
View File
@@ -57,6 +57,7 @@
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"icons": "workspace:*", "icons": "workspace:*",
"js-yaml": "^3.15.1",
"lucide-react": "*", "lucide-react": "*",
"markdown-toc": "^1.2.0", "markdown-toc": "^1.2.0",
"marketing": "workspace:*", "marketing": "workspace:*",
+3
View File
@@ -1823,6 +1823,9 @@ importers:
icons: icons:
specifier: workspace:* specifier: workspace:*
version: link:../../packages/icons version: link:../../packages/icons
js-yaml:
specifier: ^3.15.1
version: 3.15.1
lucide-react: lucide-react:
specifier: '*' specifier: '*'
version: 0.436.0(react@19.2.6) version: 0.436.0(react@19.2.6)