diff --git a/apps/kb/.gitignore b/apps/kb/.gitignore index 016b59ea143..557757712a2 100644 --- a/apps/kb/.gitignore +++ b/apps/kb/.gitignore @@ -1,6 +1,9 @@ # build output dist/ +# generated markdown export (scripts/generate-markdown.mjs) +public/markdown/ + # generated types .astro/ diff --git a/apps/kb/AGENTS.md b/apps/kb/AGENTS.md index d2093805665..4961098ac51 100644 --- a/apps/kb/AGENTS.md +++ b/apps/kb/AGENTS.md @@ -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 `.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/
/:path+.md` → `/kb/markdown/
/: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. diff --git a/apps/kb/package.json b/apps/kb/package.json index 36210f4b905..c352bcedb80 100644 --- a/apps/kb/package.json +++ b/apps/kb/package.json @@ -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", diff --git a/apps/kb/scripts/generate-markdown.mjs b/apps/kb/scripts/generate-markdown.mjs new file mode 100644 index 00000000000..ddd6f0237a7 --- /dev/null +++ b/apps/kb/scripts/generate-markdown.mjs @@ -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 `.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() +} diff --git a/apps/kb/scripts/generate-markdown.test.mjs b/apps/kb/scripts/generate-markdown.test.mjs new file mode 100644 index 00000000000..7f4c08b2edc --- /dev/null +++ b/apps/kb/scripts/generate-markdown.test.mjs @@ -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' + ) + }) +}) diff --git a/apps/kb/src/lib/topics.ts b/apps/kb/src/lib/topics.ts index ecfe5e9c764..ca47a0fb1ca 100644 --- a/apps/kb/src/lib/topics.ts +++ b/apps/kb/src/lib/topics.ts @@ -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 diff --git a/apps/kb/vercel.json b/apps/kb/vercel.json index 2c4b12e5653..e8aa76781b3 100644 --- a/apps/kb/vercel.json +++ b/apps/kb/vercel.json @@ -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*" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa3ecd83f0d..ede14323c0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}