mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
refactor(www): simplify sitemap date parsing
This commit is contained in:
1 parent
ec3ac396df
commit
2092513b04
8 files changed
+91
-60
No files matched your search
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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')]) },
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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:*",
|
||||||
|
|||||||
Generated
+3
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user