feat(kb): Expose markdown alternatives for articles and topics pages (#50135)

This commit is contained in:
Jeremias Menichelli authored and GitHub committed 2026-09-10 12:04:42 +00:00
1 parent c145f3e046
commit fd863ee15d
8 files changed
+477 -25

No files matched your search

+3
View File
@@ -1,6 +1,9 @@
# build output
dist/
# generated markdown export (scripts/generate-markdown.mjs)
public/markdown/
# generated types
.astro/
+37 -9
View File
@@ -10,6 +10,19 @@ pnpm dev:kb
pnpm dev
```
### Astro documentation
Full documentation: https://docs.astro.build
Consult these guides before working on related tasks:
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
## Build
To run a full production build, run:
@@ -22,15 +35,30 @@ pnpm build:kb
pnpm build
```
## Documentation
## Authoring content
Full documentation: https://docs.astro.build
Content lives under `src/content/guides/*.{md,mdx}`. Keep it plain GitHub-Flavored Markdown:
Consult these guides before working on related tasks:
- No custom/JSX components in guide bodies — content is also exported as plain `.md` (see below), and a
component wouldn't survive that export.
- For callouts, use GitHub's native alert syntax (`> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`,
`> [!CAUTION]`) — the build renders these into the `Admonition` component for you
(`src/lib/mdx/rehype-admonitions.ts`). Don't import or use `Admonition` directly in content.
- Frontmatter requires `title`, `description`, and `topics` (values must match `TOPIC_NAMES` in
`src/lib/topics.ts`); `pinned` and `github_url` are optional. See `src/content.config.ts` for the full schema.
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
## Pages and markdown export
Each content collection renders through a matching catch-all page — e.g. `src/content/guides/**` →
`src/pages/guides/[...slug].astro` → `GuideLayout`. Topic pages (`src/pages/topics/[topic].astro`) are
generated from the `TOPICS` list in `src/lib/topics.ts`, not from content files.
Separately, `scripts/generate-markdown.mjs` runs as a `prebuild` step and exports every content file — plus
one page per topic, listing its guides — as a plain `.md` file under the gitignored `public/markdown/`,
mirroring the page's URL with a `.md` extension. `vercel.json` permanently redirects `<page>.md` requests to
these generated files, one redirect entry per route section (`guides`, `topics`).
If you add a new content collection or top-level route, add a matching redirect in `vercel.json`
(`/kb/<section>/:path+.md` → `/kb/markdown/<section>/:path+.md`), and check whether `generate-markdown.mjs`
needs updating too — the content export falls out of its generic `src/content/**` walk automatically, but
per-topic-style listing pages don't.
+9 -1
View File
@@ -7,7 +7,9 @@
},
"scripts": {
"dev": "astro dev --port 3008",
"prebuild": "pnpm run generate:markdown",
"build": "astro build",
"generate:markdown": "node --experimental-strip-types ./scripts/generate-markdown.mjs",
"preview": "astro preview",
"astro": "astro",
"test": "vitest --run"
@@ -20,8 +22,14 @@
"lucide-react": "*",
"react": "catalog:",
"react-dom": "catalog:",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"ui": "workspace:*",
"ui-patterns": "workspace:*"
"ui-patterns": "workspace:*",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"yaml": "^2.9.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.2.4",
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env node
// Pre-build step: exports every src/content/**/*.{md,mdx} file as a plain
// .md file under public/markdown/, for agents/tools that want the raw
// content instead of the rendered page (same idea as apps/docs' guides
// markdown export, recreated here at a much smaller scale — see
// vercel.json for the redirect that serves it at `<page>.md`).
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import remarkGfm from 'remark-gfm'
import remarkParse from 'remark-parse'
import remarkStringify from 'remark-stringify'
import { unified } from 'unified'
import { visit } from 'unist-util-visit'
import YAML from 'yaml'
import { TOPICS, topicToSlug } from '../src/lib/topics.ts'
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
const CONTENT_DIR = path.join(SCRIPT_DIR, '..', 'src', 'content')
const OUTPUT_DIR = path.join(SCRIPT_DIR, '..', 'public', 'markdown')
// Production origin + Astro `base` — used to turn root-relative links into
// full URLs so the exported file still makes sense read on its own.
const SITE_ORIGIN = 'https://supabase.com'
const BASE_PATH = '/kb'
// Single processor reused for every file: parses GFM markdown to an mdast
// tree and serializes it back, same extensions on both ends so nothing
// (tables, strikethrough, alert blockquotes) gets mangled in the round trip.
// `emphasis`/`rule` match the markers content already uses (see
// src/content/guides/sample-guide.mdx) so the round trip doesn't normalize
// authors' `_italic_`/`---` into the serializer's own `*italic*`/`***`.
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkStringify, { bullet: '-', listItemIndent: 'one', emphasis: '_', rule: '-' })
/**
* Absolute base URL to prepend to root-relative links, mirroring apps/docs'
* `getInternalLinkBaseUrl()`. Empty in local dev/CI (outside Vercel), which
* keeps links relative there — resolved instead against whatever host is
* serving the build.
*
* Resolution order:
* - `VERCEL_ENV=production` → `https://supabase.com`
* - `VERCEL_ENV=preview` → `https://${VERCEL_URL}`
* - anything else → ''
*/
export function getInternalLinkBaseUrl() {
const env = process.env.VERCEL_ENV
if (env === 'production') return SITE_ORIGIN
if (env === 'preview' && process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
return ''
}
/**
* Splits a `.md`/`.mdx` file's raw text into its YAML frontmatter (parsed
* to a plain object) and the remaining body. The delimiters are located
* with plain string ops, and the frontmatter itself is parsed with a real
* YAML parser — no regex involved in extracting values.
*
* @param {string} raw
*/
export function parseFrontmatter(raw) {
if (!raw.startsWith('---\n')) return { data: {}, body: raw }
const frontmatterEnd = raw.indexOf('\n---', 4)
if (frontmatterEnd === -1) return { data: {}, body: raw }
const yamlText = raw.slice(4, frontmatterEnd)
const bodyStart = raw.indexOf('\n', frontmatterEnd + 1)
const body = bodyStart === -1 ? '' : raw.slice(bodyStart + 1)
return { data: YAML.parse(yamlText) ?? {}, body }
}
/**
* Rewrites root-relative markdown links into full, absolute URLs by parsing
* the body to an mdast tree, visiting its `link` nodes, and serializing it
* back — the AST equivalent of apps/docs' `addBaseUrlPrefix()`, so this file
* still makes sense read outside of the site (no regex over the raw text).
*
* @param {string} body
*/
export function absolutizeLinks(body) {
const tree = processor.parse(body)
const baseUrl = getInternalLinkBaseUrl()
visit(tree, 'link', (node) => {
if (!node.url.startsWith('/') || node.url.startsWith('//')) return
const withBase =
node.url === BASE_PATH || node.url.startsWith(`${BASE_PATH}/`)
? node.url
: `${BASE_PATH}${node.url}`
node.url = `${baseUrl}${withBase}`
})
return String(processor.stringify(tree))
}
/**
* Renders the simplified export: the frontmatter title as an h1, the
* description as the paragraph beneath it, then the (link-absolutized)
* body.
*
* @param {{ title?: string; description?: string }} data
* @param {string} body
*/
export function renderMarkdown(data, body) {
const heading = data.title ? `# ${data.title}\n\n` : ''
const lead = data.description ? `${data.description}\n\n` : ''
// absolutizeLinks() already normalizes to exactly one trailing newline
// (remark-stringify's doing, not ours) — no extra "\n" needed here.
return `${heading}${lead}${absolutizeLinks(body.trim())}`
}
/**
* Renders a topic index: its name as an h1, its description as the
* paragraph beneath it, then a bullet list linking to every guide tagged
* with that topic — same data `src/pages/topics/[topic].astro` renders,
* as a plain markdown page.
*
* @param {{ name: string; description: string }} topic
* @param {{ title: string; url: string }[]} guides
*/
export function renderTopicMarkdown(topic, guides) {
const list = guides.length
? guides.map((guide) => `- [${guide.title}](${guide.url})`).join('\n')
: 'No guides for this topic'
return `# ${topic.name}\n\n${topic.description}\n\n${list}\n`
}
// file.mdx -> file.md, file.md -> file.md — no regex, just a suffix swap.
function toMdExtension(fileName) {
if (fileName.endsWith('.mdx')) return `${fileName.slice(0, -'.mdx'.length)}.md`
return fileName
}
// Strips the .md/.mdx extension off a content-relative path and turns it
// into the full, absolute URL of that page's markdown export.
function toGuideUrl(relativePath) {
const posixPath = relativePath.split(path.sep).join('/')
const withoutExt = toMdExtension(posixPath).slice(0, -'.md'.length)
return `${SITE_ORIGIN}${BASE_PATH}/${withoutExt}.md`
}
async function findContentFiles(dir) {
const entries = await readdir(dir, { withFileTypes: true })
const files = await Promise.all(
entries.map((entry) => {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) return findContentFiles(fullPath)
if (entry.name.endsWith('.md') || entry.name.endsWith('.mdx')) return [fullPath]
return []
})
)
return files.flat()
}
async function writeFileEnsuringDir(outputPath, contents) {
await mkdir(path.dirname(outputPath), { recursive: true })
await writeFile(outputPath, contents)
}
async function main() {
const files = await findContentFiles(CONTENT_DIR)
// Only entries with a `topics` array (currently just the `guides`
// collection) feed the per-topic index pages below.
const guidesByTopic = new Map(TOPICS.map((topic) => [topic.name, []]))
await Promise.all(
files.map(async (file) => {
const raw = await readFile(file, 'utf-8')
const { data, body } = parseFrontmatter(raw)
const relativePath = path.relative(CONTENT_DIR, file)
const markdown = renderMarkdown(data, body)
const outputPath = path.join(OUTPUT_DIR, toMdExtension(relativePath))
await writeFileEnsuringDir(outputPath, markdown)
if (!Array.isArray(data.topics)) return
const guide = { title: data.title, url: toGuideUrl(relativePath) }
for (const topicName of data.topics) {
guidesByTopic.get(topicName)?.push(guide)
}
})
)
await Promise.all(
TOPICS.map((topic) => {
const markdown = renderTopicMarkdown(topic, guidesByTopic.get(topic.name))
const outputPath = path.join(OUTPUT_DIR, 'topics', `${topicToSlug(topic.name)}.md`)
return writeFileEnsuringDir(outputPath, markdown)
})
)
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main()
}
+177
View File
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
absolutizeLinks,
getInternalLinkBaseUrl,
parseFrontmatter,
renderMarkdown,
renderTopicMarkdown,
} from './generate-markdown.mjs'
describe('parseFrontmatter', () => {
it('parses title/description out of the YAML frontmatter and returns the rest as body', () => {
const raw = `---\ntitle: 'Sample guide'\ndescription: 'A short summary.'\n---\n\nBody text here.\n`
const { data, body } = parseFrontmatter(raw)
expect(data.title).toBe('Sample guide')
expect(data.description).toBe('A short summary.')
// The blank line separating the closing `---` from the body is kept as-is
// here — renderMarkdown() is what trims it before composing the output.
expect(body).toBe('\nBody text here.\n')
})
it('parses values that would trip up a naive regex (colons and quotes in the text)', () => {
const raw = `---\ntitle: "Note: this has a colon"\ndescription: "Quoted 'inner' text: still one value"\n---\nBody\n`
const { data } = parseFrontmatter(raw)
expect(data.title).toBe('Note: this has a colon')
expect(data.description).toBe("Quoted 'inner' text: still one value")
})
it('returns an empty data object and the raw text untouched when there is no frontmatter', () => {
const raw = 'Just a plain paragraph, no frontmatter.\n'
const { data, body } = parseFrontmatter(raw)
expect(data).toEqual({})
expect(body).toBe(raw)
})
})
describe('getInternalLinkBaseUrl', () => {
const ORIGINAL_ENV = process.env
beforeEach(() => {
process.env = { ...ORIGINAL_ENV }
delete process.env.VERCEL_ENV
delete process.env.VERCEL_URL
})
afterEach(() => {
process.env = ORIGINAL_ENV
})
it('returns the production origin when VERCEL_ENV=production', () => {
process.env.VERCEL_ENV = 'production'
expect(getInternalLinkBaseUrl()).toBe('https://supabase.com')
})
it('returns the deployment URL when VERCEL_ENV=preview', () => {
process.env.VERCEL_ENV = 'preview'
process.env.VERCEL_URL = 'kb-git-fork-supabase.vercel.app'
expect(getInternalLinkBaseUrl()).toBe('https://kb-git-fork-supabase.vercel.app')
})
it('returns empty when preview is set but VERCEL_URL is missing', () => {
process.env.VERCEL_ENV = 'preview'
expect(getInternalLinkBaseUrl()).toBe('')
})
it('returns empty when VERCEL_ENV is not set (local dev/CI)', () => {
expect(getInternalLinkBaseUrl()).toBe('')
})
})
describe('absolutizeLinks', () => {
const ORIGINAL_ENV = process.env
beforeEach(() => {
process.env = { ...ORIGINAL_ENV, VERCEL_ENV: 'production' }
})
afterEach(() => {
process.env = ORIGINAL_ENV
})
it('leaves already-absolute links untouched', () => {
expect(absolutizeLinks('See [the docs](https://example.com/guide) for more.')).toBe(
'See [the docs](https://example.com/guide) for more.\n'
)
})
it('rewrites a root-relative link into a full URL under the kb base path', () => {
expect(absolutizeLinks('See [another guide](/guides/other-guide) for more.')).toBe(
'See [another guide](https://supabase.com/kb/guides/other-guide) for more.\n'
)
})
it('does not double up the base path when a link already includes it', () => {
expect(absolutizeLinks('[another guide](/kb/guides/other-guide)')).toBe(
'[another guide](https://supabase.com/kb/guides/other-guide)\n'
)
})
it('leaves the link relative (base path only, no origin) outside of Vercel', () => {
process.env.VERCEL_ENV = undefined
expect(absolutizeLinks('[another guide](/guides/other-guide)')).toBe(
'[another guide](/kb/guides/other-guide)\n'
)
})
it('does not rewrite image URLs', () => {
expect(absolutizeLinks('![alt](/img.png)')).toBe('![alt](/img.png)\n')
})
it('skips link-like text inside fenced code blocks', () => {
expect(absolutizeLinks('```\n[x](/x)\n```\n\n[y](/y)')).toBe(
'```\n[x](/x)\n```\n\n[y](https://supabase.com/kb/y)\n'
)
})
it('leaves a root-relative URL inside a fenced code block untouched, even outside link syntax', () => {
const body = '```bash\ncurl -X GET /guides/foo\n```'
expect(absolutizeLinks(body)).toBe('```bash\ncurl -X GET /guides/foo\n```\n')
})
it('leaves a root-relative URL inside a fenced markdown code block untouched', () => {
const body = '```md\n[Guides](/guides/foo)\n```'
expect(absolutizeLinks(body)).toBe('```md\n[Guides](/guides/foo)\n```\n')
})
it('leaves a root-relative URL inside an inline code span untouched', () => {
expect(absolutizeLinks('Run `GET /guides/foo` to fetch it.')).toBe(
'Run `GET /guides/foo` to fetch it.\n'
)
})
it('round-trips GFM tables and strikethrough without mangling them', () => {
const body = '| a | b |\n| - | - |\n| 1 | 2 |\n\n~~gone~~'
expect(absolutizeLinks(body)).toBe('| a | b |\n| - | - |\n| 1 | 2 |\n\n~~gone~~\n')
})
})
describe('renderMarkdown', () => {
it('turns the title into an h1 and the description into the paragraph beneath it', () => {
const data = { title: 'Sample guide', description: 'A short summary.' }
expect(renderMarkdown(data, 'Body text.')).toBe(
'# Sample guide\n\nA short summary.\n\nBody text.\n'
)
})
it('skips the heading/lead lines gracefully when title or description are missing', () => {
expect(renderMarkdown({}, 'Body text.')).toBe('Body text.\n')
})
})
describe('renderTopicMarkdown', () => {
const topic = { name: 'Tutorial', description: 'Step-by-step walkthroughs.' }
it('renders the topic as an h1/description followed by a bullet list of its guides', () => {
const guides = [
{ title: 'Sample guide', url: 'https://supabase.com/kb/guides/sample-guide.md' },
]
expect(renderTopicMarkdown(topic, guides)).toBe(
'# Tutorial\n\nStep-by-step walkthroughs.\n\n- [Sample guide](https://supabase.com/kb/guides/sample-guide.md)\n'
)
})
it('falls back to "No guides for this topic" when the list is empty', () => {
expect(renderTopicMarkdown(topic, [])).toBe(
'# Tutorial\n\nStep-by-step walkthroughs.\n\nNo guides for this topic\n'
)
})
})
+15 -11
View File
@@ -8,33 +8,37 @@
export const TOPICS = [
{
name: 'Migration',
description: 'Moving data, schemas, or projects onto Supabase',
description: 'Moving data, schemas, or projects onto Supabase.',
pinned: false,
},
{
name: 'Comparison',
description: 'How Supabase compares to other databases and platforms',
description: 'How Supabase compares to other databases and platforms.',
pinned: false,
},
{ name: 'Troubleshooting', description: 'Common errors and how to resolve them', pinned: false },
{ name: 'Troubleshooting', description: 'Common errors and how to resolve them.', pinned: false },
{
name: 'Tutorial',
description: 'Step-by-step walkthroughs for building with Supabase',
description: 'Step-by-step walkthroughs for building with Supabase.',
pinned: true,
},
{ name: 'Storage', description: 'Uploading, managing, and serving files', pinned: false },
{ name: 'Auth', description: 'Authentication, authorization, and user management', pinned: true },
{ name: 'Database', description: 'Postgres schemas, queries, and performance', pinned: true },
{ name: 'Storage', description: 'Uploading, managing, and serving files.', pinned: false },
{
name: 'Auth',
description: 'Authentication, authorization, and user management.',
pinned: true,
},
{ name: 'Database', description: 'Postgres schemas, queries, and performance.', pinned: true },
{
name: 'Edge Functions',
description: 'Deploying and running serverless functions',
description: 'Deploying and running serverless functions.',
pinned: false,
},
{ name: 'Queues', description: 'Background jobs and message processing', pinned: false },
{ name: 'Realtime', description: 'Broadcast, presence, and database changes', pinned: false },
{ name: 'Queues', description: 'Background jobs and message processing.', pinned: false },
{ name: 'Realtime', description: 'Broadcast, presence, and database changes.', pinned: false },
{
name: 'Supabase Platform',
description: 'Project settings, billing, and infrastructure',
description: 'Project settings, billing, and infrastructure.',
pinned: false,
},
] as const
+13 -1
View File
@@ -1,6 +1,18 @@
{
"buildCommand": "pnpm build",
"redirects": [{ "source": "/", "destination": "/kb", "permanent": false }],
"redirects": [
{ "source": "/", "destination": "/kb", "permanent": false },
{
"source": "/kb/guides/:path+.md",
"destination": "/kb/markdown/guides/:path+.md",
"permanent": true
},
{
"source": "/kb/topics/:path+.md",
"destination": "/kb/markdown/topics/:path+.md",
"permanent": true
}
],
"rewrites": [
{ "source": "/kb", "destination": "/index.html" },
{ "source": "/kb/:path*", "destination": "/:path*" }
+21 -3
View File
@@ -750,12 +750,30 @@ importers:
react-dom:
specifier: 'catalog:'
version: 19.2.6(react@19.2.6)
remark-gfm:
specifier: ^4.0.1
version: 4.0.1(supports-color@8.1.1)
remark-parse:
specifier: ^11.0.0
version: 11.0.0(supports-color@8.1.1)
remark-stringify:
specifier: ^11.0.0
version: 11.0.0
ui:
specifier: workspace:*
version: link:../../packages/ui
ui-patterns:
specifier: workspace:*
version: link:../../packages/ui-patterns
unified:
specifier: ^11.0.5
version: 11.0.5
unist-util-visit:
specifier: ^5.1.0
version: 5.1.0
yaml:
specifier: ^2.9.0
version: 2.9.0
devDependencies:
'@tailwindcss/vite':
specifier: 4.2.4
@@ -31261,7 +31279,7 @@ snapshots:
'@types/mdast': 4.0.4
escape-string-regexp: 5.0.0
unist-util-is: 6.0.0
unist-util-visit-parents: 6.0.1
unist-util-visit-parents: 6.0.2
mdast-util-from-markdown@1.3.1(supports-color@8.1.1):
dependencies:
@@ -36267,7 +36285,7 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.0
unist-util-visit-parents: 6.0.1
unist-util-visit-parents: 6.0.2
unist-util-stringify-position@2.0.3:
dependencies:
@@ -36316,7 +36334,7 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.0
unist-util-visit-parents: 6.0.1
unist-util-visit-parents: 6.0.2
universal-github-app-jwt@2.2.0: {}