mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
The rss didn't order items based on specified date and time but it trimmed each item to start of day, making the sorting fall back to alphabetical and not triggering the "latest" rss atom in case of same-day items but ordered based on time. ## Before The "Supabase Series F" blog post is not ordered as latest in the rss even though it was published with a later timestamp, not triggering rss feeds. <img width="1217" height="455" alt="Screenshot 2026-06-10 at 12 40 24" src="https://github.com/user-attachments/assets/7ac77d8c-26b8-4073-9474-ee03fd318240" /> Blog is ordered correctly: <img width="1309" height="680" alt="Screenshot 2026-06-10 at 12 42 10" src="https://github.com/user-attachments/assets/58f1d683-1254-468e-a2da-0eeebf1bc862" /> ## After The pr doesn't show the result because the rss feed is .gitignored and generated only on prod build. But this is the rss generated locally for reference: <img width="713" height="497" alt="Screenshot 2026-06-10 at 12 35 52" src="https://github.com/user-attachments/assets/4dade2f9-a8cb-4b03-a234-1d89fdbf29c5" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Blog posts published on the same day are now sorted by their publication time. * RSS feed publication dates now retain the exact publication time instead of being rounded down to the start of the day. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude <noreply@anthropic.com>
490 lines
16 KiB
JavaScript
490 lines
16 KiB
JavaScript
// @ts-check
|
|
|
|
import fsSync, { promises as fs } from 'fs'
|
|
import path from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
import dayjs from 'dayjs'
|
|
import advancedFormat from 'dayjs/plugin/advancedFormat.js'
|
|
import utc from 'dayjs/plugin/utc.js'
|
|
import matter from 'gray-matter'
|
|
|
|
import { getGitHubStars } from './lib/githubStars.mjs'
|
|
|
|
/**
|
|
* Plain `node` does not read `.env` / `.env.local` (Next.js loads those when you run `next`).
|
|
* Minimal parser: no extra dependency; `.env` first, then `.env.local` overrides.
|
|
*/
|
|
function loadLocalEnvFiles(rootDir) {
|
|
const parseValue = (raw) => {
|
|
const val = raw.trim()
|
|
if (val.startsWith('"') && val.endsWith('"')) {
|
|
return val.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
|
}
|
|
if (val.startsWith("'") && val.endsWith("'")) {
|
|
return val.slice(1, -1).replace(/\\n/g, '\n').replace(/\\'/g, "'").replace(/\\\\/g, '\\')
|
|
}
|
|
return val
|
|
}
|
|
|
|
const applyLine = (line, override) => {
|
|
const trimmed = line.trim()
|
|
if (!trimmed || trimmed.startsWith('#')) return
|
|
const eq = trimmed.indexOf('=')
|
|
if (eq === -1) return
|
|
const key = trimmed
|
|
.slice(0, eq)
|
|
.trim()
|
|
.replace(/^export\s+/i, '')
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return
|
|
const value = parseValue(trimmed.slice(eq + 1))
|
|
if (override || process.env[key] === undefined) {
|
|
process.env[key] = value
|
|
}
|
|
}
|
|
|
|
for (const name of ['.env', '.env.local']) {
|
|
try {
|
|
const fp = path.join(rootDir, name)
|
|
const raw = fsSync.readFileSync(fp, 'utf8')
|
|
const override = name === '.env.local'
|
|
for (const line of raw.split(/\r?\n/)) {
|
|
applyLine(line, override)
|
|
}
|
|
} catch {
|
|
/* file missing */
|
|
}
|
|
}
|
|
}
|
|
|
|
const wwwRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
loadLocalEnvFiles(wwwRoot)
|
|
|
|
dayjs.extend(utc)
|
|
dayjs.extend(advancedFormat)
|
|
|
|
// Constants
|
|
const FILENAME_SUBSTRING = 11 // based on YYYY-MM-DD format
|
|
const warnedBlogImageIssues = new Set()
|
|
|
|
const warnBlogImageIssue = (key, message) => {
|
|
if (warnedBlogImageIssues.has(key)) return
|
|
|
|
warnedBlogImageIssues.add(key)
|
|
console.warn(message)
|
|
}
|
|
|
|
const validateBlogFrontmatterImages = (frontmatter, filePath) => {
|
|
const hasImgSocial = typeof frontmatter.imgSocial !== 'undefined'
|
|
const hasImgThumb = typeof frontmatter.imgThumb !== 'undefined'
|
|
|
|
if (hasImgSocial && !hasImgThumb) {
|
|
warnBlogImageIssue(
|
|
`${filePath}:imgThumb:missing`,
|
|
`[blog images] ${filePath}: missing "imgThumb". Adding it keeps on-site thumbnails separate from social previews.`
|
|
)
|
|
}
|
|
|
|
if (hasImgThumb && !hasImgSocial) {
|
|
warnBlogImageIssue(
|
|
`${filePath}:imgSocial:missing`,
|
|
`[blog images] ${filePath}: missing "imgSocial". Adding it keeps social previews separate from on-site thumbnails.`
|
|
)
|
|
}
|
|
|
|
const imageFields = [
|
|
['imgSocial', frontmatter.imgSocial],
|
|
['imgThumb', frontmatter.imgThumb],
|
|
]
|
|
|
|
for (const [fieldName, imageValue] of imageFields) {
|
|
if (typeof imageValue === 'undefined') {
|
|
continue
|
|
}
|
|
|
|
if (typeof imageValue !== 'string') {
|
|
warnBlogImageIssue(
|
|
`${filePath}:${fieldName}:invalid-type`,
|
|
`[blog images] ${filePath}: "${fieldName}" should be a string URL or a relative blog image path.`
|
|
)
|
|
continue
|
|
}
|
|
|
|
const trimmedValue = imageValue.trim()
|
|
|
|
if (!trimmedValue) {
|
|
warnBlogImageIssue(
|
|
`${filePath}:${fieldName}:empty`,
|
|
`[blog images] ${filePath}: "${fieldName}" is empty. Remove it or provide a valid image path.`
|
|
)
|
|
continue
|
|
}
|
|
|
|
if (trimmedValue.startsWith('/images/blog/')) {
|
|
warnBlogImageIssue(
|
|
`${filePath}:${fieldName}:prefixed`,
|
|
`[blog images] ${filePath}: "${fieldName}" should not include the "/images/blog/" prefix. Use a relative path like "my-post/og.png" instead.`
|
|
)
|
|
}
|
|
|
|
if (trimmedValue.startsWith('./') || trimmedValue.startsWith('../')) {
|
|
warnBlogImageIssue(
|
|
`${filePath}:${fieldName}:relative-dot`,
|
|
`[blog images] ${filePath}: "${fieldName}" should use a clean relative blog path, not "${trimmedValue}".`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sort posts by their full publish timestamp to keep same-day posts in the correct order
|
|
*/
|
|
const sortDates = (a, b, direction = 'desc') => {
|
|
const diff = new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
return direction === 'asc' ? diff : -diff
|
|
}
|
|
|
|
/**
|
|
* Get static blog posts from _blog directory
|
|
*/
|
|
const getStaticBlogPosts = () => {
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
const postDirectory = path.join(__dirname, '../_blog')
|
|
|
|
try {
|
|
const fileNames = fsSync.readdirSync(postDirectory)
|
|
|
|
const allPosts = fileNames
|
|
.filter((filename) => filename.endsWith('.mdx'))
|
|
.map((filename) => {
|
|
const slug = filename.replace('.mdx', '').substring(FILENAME_SUBSTRING)
|
|
const fullPath = path.join(postDirectory, filename)
|
|
|
|
// Extract contents of the MDX file
|
|
const fileContents = fsSync.readFileSync(fullPath, 'utf8')
|
|
const { data } = matter(fileContents)
|
|
|
|
validateBlogFrontmatterImages(data, fullPath)
|
|
|
|
const options = { month: 'long', day: 'numeric', year: 'numeric' }
|
|
const formattedDate = new Date(data.date).toLocaleDateString('en-IN', options)
|
|
|
|
return {
|
|
slug,
|
|
title: data.title,
|
|
description: data.description,
|
|
date: data.date,
|
|
formattedDate,
|
|
url: `/blog/${slug}`,
|
|
isStatic: true,
|
|
...data,
|
|
}
|
|
})
|
|
.filter((post) => post.title && post.description && post.date)
|
|
|
|
return allPosts
|
|
} catch (error) {
|
|
console.warn('Error reading static blog posts:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all blog posts from both sources
|
|
*/
|
|
const getAllBlogPosts = async () => {
|
|
const staticPosts = getStaticBlogPosts()
|
|
|
|
// Combine and sort all posts by date
|
|
const allPosts = [...staticPosts]
|
|
|
|
// Filter out posts without valid dates and sort
|
|
const validPosts = allPosts.sort((a, b) => sortDates(a, b, 'desc'))
|
|
|
|
return validPosts
|
|
}
|
|
|
|
/**
|
|
* Get latest blog posts from both sources
|
|
*/
|
|
const getLatestBlogPosts = async () => {
|
|
const allPosts = await getAllBlogPosts()
|
|
|
|
// Return latest 10 posts
|
|
const latestPosts = allPosts
|
|
.slice(0, 10)
|
|
.map(({ title, url, description, date, formattedDate }) => ({
|
|
title,
|
|
url,
|
|
description,
|
|
date,
|
|
formattedDate,
|
|
}))
|
|
|
|
return latestPosts
|
|
}
|
|
|
|
// GitHub Stars
|
|
const stars = await getGitHubStars(path.join(wwwRoot, '.generated/staticContent/_index.json'))
|
|
|
|
// Careers Jobs count
|
|
const getCareerCount = async () => {
|
|
try {
|
|
const job_res = await fetch('https://api.ashbyhq.com/posting-api/job-board/supabase')
|
|
const job_data = await job_res.json()
|
|
return job_data.jobs.length
|
|
} catch (error) {
|
|
console.warn('Error fetching career count:', error)
|
|
return 0
|
|
}
|
|
}
|
|
|
|
let careersCount = 0
|
|
|
|
try {
|
|
careersCount = await getCareerCount()
|
|
} catch (error) {
|
|
console.warn('Error getting career count:', error)
|
|
}
|
|
|
|
// Get latest blog posts
|
|
let latestBlogPosts = []
|
|
try {
|
|
latestBlogPosts = await getLatestBlogPosts()
|
|
} catch (error) {
|
|
console.warn('Error getting latest blog posts:', error)
|
|
latestBlogPosts = []
|
|
}
|
|
|
|
// Create folder for static content
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
const folderPath = path.join(__dirname, '../.generated/staticContent')
|
|
try {
|
|
await fs.mkdir(folderPath, { recursive: true })
|
|
} catch (error) {
|
|
if (error.code !== 'EEXIST') {
|
|
throw error
|
|
}
|
|
// Folder already exists, continue silently
|
|
}
|
|
|
|
// Write static content to file
|
|
const filePath = path.join(__dirname, '../.generated/staticContent/_index.json')
|
|
await fs.writeFile(
|
|
filePath,
|
|
JSON.stringify(
|
|
{
|
|
latestBlogPosts: latestBlogPosts,
|
|
jobsCount: careersCount,
|
|
githubStars: stars,
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
'utf8'
|
|
)
|
|
|
|
console.log(`✅ Generated static content with ${latestBlogPosts.length} latest blog posts`)
|
|
|
|
// Generate blog and changelog RSS feed
|
|
try {
|
|
const allBlogPosts = await getAllBlogPosts()
|
|
|
|
// Transform posts to RSS format
|
|
const xmlEncode = (str) => {
|
|
if (str === undefined || str === null) {
|
|
return ''
|
|
}
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
const generateRssItem = (post) => {
|
|
const encodedTitle = xmlEncode(post.title)
|
|
const encodedPath = xmlEncode(post.path || post.url || `/blog/${post.slug}`)
|
|
const encodedDescription = xmlEncode(post.description)
|
|
const formattedDate = dayjs(post.date)
|
|
.utcOffset(0, true)
|
|
.format('ddd, DD MMM YYYY HH:mm:ss [-0700]')
|
|
|
|
return `<item>
|
|
<guid>https://supabase.com${encodedPath}</guid>
|
|
<title>${encodedTitle}</title>
|
|
<link>https://supabase.com${encodedPath}</link>
|
|
<description>${encodedDescription}</description>
|
|
<pubDate>${formattedDate}</pubDate>
|
|
</item>
|
|
`
|
|
}
|
|
|
|
const formattedDate =
|
|
allBlogPosts.length > 0
|
|
? dayjs(allBlogPosts[0].date)
|
|
.utcOffset(0, true)
|
|
.startOf('day')
|
|
.format('ddd, DD MMM YYYY HH:mm:ss [-0700]')
|
|
: dayjs().utcOffset(0, true).startOf('day').format('ddd, DD MMM YYYY HH:mm:ss [-0700]')
|
|
|
|
const rss = `
|
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
<channel>
|
|
<title>Supabase Blog</title>
|
|
<link>https://supabase.com</link>
|
|
<description>Latest news from Supabase</description>
|
|
<language>en</language>
|
|
<lastBuildDate>${formattedDate}</lastBuildDate>
|
|
<atom:link href="https://supabase.com/rss.xml" rel="self" type="application/rss+xml"/>
|
|
${allBlogPosts.map(generateRssItem).join('')}
|
|
</channel>
|
|
</rss>
|
|
`
|
|
|
|
// Write RSS feed to public directory
|
|
const rssPath = path.join(__dirname, '../public/rss.xml')
|
|
await fs.writeFile(rssPath, rss.trim(), 'utf8')
|
|
console.log(`✅ Generated RSS feed with ${allBlogPosts.length} entries`)
|
|
} catch (error) {
|
|
console.warn('Error generating RSS feed:', error)
|
|
}
|
|
|
|
// Changelog RSS + changelog.md → sourced from supabase/changelog entries/*.md (private repo).
|
|
// Missing secret: warns and skips outside Vercel, but fails Vercel builds so they can't
|
|
// publish stale generated changelog files. Generic CI (typecheck/lint on GitHub Actions)
|
|
// has no access to this secret and isn't publishing anything, so it only warns too.
|
|
async function generateChangelogContent() {
|
|
const appId = process.env.CHANGELOG_SYNC_APP_ID
|
|
const installationId = process.env.CHANGELOG_SYNC_APP_INSTALLATION_ID
|
|
const privateKey = process.env.CHANGELOG_SYNC_APP_PRIVATE_KEY
|
|
|
|
if (!appId || !installationId || !privateKey) {
|
|
if (process.env.VERCEL) {
|
|
throw new Error('CHANGELOG_SYNC_APP_* env vars not set — cannot generate changelog content')
|
|
}
|
|
console.warn('⚠️ CHANGELOG_SYNC_APP_* env vars not set — skipping changelog RSS/md generation')
|
|
return
|
|
}
|
|
|
|
const { getPublishedChangelogEntries, fetchChangelogEntryFilesFromTarball, CHANGE_TYPE_LABELS } =
|
|
await import('../lib/changelog-entries-core.mjs')
|
|
const { generateChangelogRssXml, generateChangelogTagRssXml, labelToFileSlug } =
|
|
await import('../lib/changelog-rss.mjs')
|
|
const { createAppAuth } = await import('@octokit/auth-app')
|
|
const { Octokit } = await import('@octokit/core')
|
|
const octokit = new Octokit({
|
|
authStrategy: createAppAuth,
|
|
auth: { appId, installationId, privateKey: privateKey.replace(/\\n/g, '\n') },
|
|
})
|
|
|
|
// Single tarball request — fetching each entry file individually trips
|
|
// GitHub's secondary rate limit once the entries directory gets large.
|
|
const files = await fetchChangelogEntryFilesFromTarball(octokit, {
|
|
owner: 'supabase',
|
|
repo: 'changelog',
|
|
entriesPath: 'entries',
|
|
})
|
|
const entries = getPublishedChangelogEntries(files)
|
|
|
|
const rssEntries = entries.map((entry) => ({
|
|
slug: entry.slug,
|
|
title: entry.frontmatter.title,
|
|
sortDate: entry.sortDate,
|
|
affectedProducts: entry.frontmatter.affected_products ?? [],
|
|
}))
|
|
|
|
const changelogXml = generateChangelogRssXml(rssEntries)
|
|
const changelogRssPath = path.join(__dirname, '../public/changelog-rss.xml')
|
|
await fs.writeFile(changelogRssPath, changelogXml.trim(), 'utf8')
|
|
console.log(`✅ Generated changelog RSS with ${entries.length} entries`)
|
|
|
|
// Per-tag feeds → public/changelog-rss/<label-slug>.xml
|
|
const productTagsPath = path.join(__dirname, '../data/changelog-product-tags.json')
|
|
const productTags = JSON.parse(await fs.readFile(productTagsPath, 'utf8'))
|
|
const tagFeedsDir = path.join(__dirname, '../public/changelog-rss')
|
|
// Clear first so a renamed/removed product tag doesn't leave a stale feed file behind.
|
|
await fs.rm(tagFeedsDir, { recursive: true, force: true })
|
|
await fs.mkdir(tagFeedsDir, { recursive: true })
|
|
const tagFilenames = productTags.map(({ label }) => `${labelToFileSlug(label)}.xml`)
|
|
const tagResults = await Promise.allSettled(
|
|
productTags.map(async ({ label }) => {
|
|
const fileSlug = labelToFileSlug(label)
|
|
const tagXml = generateChangelogTagRssXml(rssEntries, { displayLabel: label })
|
|
await fs.writeFile(path.join(tagFeedsDir, `${fileSlug}.xml`), tagXml.trim(), 'utf8')
|
|
})
|
|
)
|
|
const failedTagFeeds = tagResults.flatMap((result, i) =>
|
|
result.status === 'rejected' ? [{ file: tagFilenames[i], reason: result.reason }] : []
|
|
)
|
|
const succeeded = tagResults.length - failedTagFeeds.length
|
|
console.log(`✅ Generated ${succeeded}/${productTags.length} per-tag changelog RSS feeds`)
|
|
if (failedTagFeeds.length > 0) {
|
|
for (const { file, reason } of failedTagFeeds) {
|
|
console.error(`Failed to write changelog-rss/${file}:`, reason)
|
|
}
|
|
throw new Error(
|
|
`Failed to generate ${failedTagFeeds.length}/${productTags.length} per-tag changelog RSS feeds`
|
|
)
|
|
}
|
|
|
|
// LLM-friendly changelog markdown index (RSS remains canonical syndication format).
|
|
const mdSections = entries.map((entry) => {
|
|
const date = dayjs(entry.sortDate).isValid() ? dayjs(entry.sortDate).format('YYYY-MM-DD') : ''
|
|
const changeType =
|
|
CHANGE_TYPE_LABELS[entry.frontmatter.change_type] ?? entry.frontmatter.change_type
|
|
const products = (entry.frontmatter.affected_products ?? []).join(', ')
|
|
const meta = [
|
|
date,
|
|
changeType,
|
|
products,
|
|
`[supabase.com/changelog/${entry.slug}](https://supabase.com/changelog/${entry.slug})`,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ')
|
|
return [`## ${entry.frontmatter.title}`, meta, entry.summary].filter(Boolean).join('\n\n')
|
|
})
|
|
const changelogMd = `# Supabase Changelog\n\n${mdSections.join('\n\n---\n\n')}\n`
|
|
const changelogMdPath = path.join(__dirname, '../public/changelog.md')
|
|
await fs.writeFile(changelogMdPath, changelogMd, 'utf8')
|
|
console.log(`✅ Generated changelog.md (${entries.length} entries)`)
|
|
|
|
// One markdown file per entry → /changelog/<slug>.md (Body section only — internal notes never included).
|
|
const changelogEntryMdDir = path.join(__dirname, '../public/changelog')
|
|
// Clear first so a renamed/unpublished entry doesn't leave a stale file behind.
|
|
await fs.rm(changelogEntryMdDir, { recursive: true, force: true })
|
|
await fs.mkdir(changelogEntryMdDir, { recursive: true })
|
|
for (const entry of entries) {
|
|
const published = dayjs(entry.sortDate).isValid()
|
|
? dayjs(entry.sortDate).format('YYYY-MM-DD')
|
|
: ''
|
|
const titleLine = String(entry.frontmatter.title ?? '')
|
|
.replace(/\n/g, ' ')
|
|
.trim()
|
|
const productsYaml = (entry.frontmatter.affected_products ?? [])
|
|
.map((p) => ` - ${p}`)
|
|
.join('\n')
|
|
const pageUrl = `https://supabase.com/changelog/${entry.slug}`
|
|
const entryMd = `---
|
|
slug: ${entry.slug}
|
|
published: ${published}
|
|
change_type: ${entry.frontmatter.change_type}
|
|
affected_products:
|
|
${productsYaml || ' []'}
|
|
page: ${pageUrl}
|
|
---
|
|
|
|
# ${titleLine}
|
|
|
|
${entry.bodySection}
|
|
`
|
|
await fs.writeFile(
|
|
path.join(changelogEntryMdDir, `${entry.slug}.md`),
|
|
entryMd.trim() + '\n',
|
|
'utf8'
|
|
)
|
|
}
|
|
console.log(`✅ Generated changelog/*.md (${entries.length} files)`)
|
|
}
|
|
await generateChangelogContent()
|