diff --git a/netlify/edge-functions/md-negotiate.ts b/netlify/edge-functions/md-negotiate.ts new file mode 100644 index 000000000..3942724f4 --- /dev/null +++ b/netlify/edge-functions/md-negotiate.ts @@ -0,0 +1,32 @@ +import type { Config, Context } from "@netlify/edge-functions"; + +export default async function handler(req: Request, ctx: Context) { + const accept = req.headers.get("Accept") ?? ""; + if (!accept.includes("text/markdown") && !accept.includes("text/plain")) { + return ctx.next(); + } + + const url = new URL(req.url); + const slug = url.pathname + .replace(/^\/docs\//, "") + .replace(/\/$/, ""); + + if (!slug) { + return ctx.next(); + } + + const mdUrl = new URL(`/_md/docs/${slug}.md`, url.origin); + const res = await fetch(mdUrl); + if (!res.ok) { + return ctx.next(); + } + + return new Response(await res.text(), { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Vary": "Accept", + }, + }); +} + +export const config: Config = { path: "/docs/*" }; diff --git a/package.json b/package.json index 7d6682247..e4bc1b1e1 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "fetch-downloads-info": "tsx scripts/fetch-downloads-info.ts", "clean": "rm -rf generated out .next", "build": "next build", - "postbuild": "pagefind --site out", + "postbuild": "pagefind --site out && tsx scripts/copy-md.ts", "build-all": "npm run clean && npm run fetch-repo-docs && npm run fetch-downloads-info && npm run build", "start": "next start", "lint": "next lint" diff --git a/scripts/copy-md.ts b/scripts/copy-md.ts new file mode 100644 index 000000000..1b707af14 --- /dev/null +++ b/scripts/copy-md.ts @@ -0,0 +1,104 @@ +#!/usr/bin/env tsx +// Copies each markdown source file into out/_md/docs/.md so that the +// Netlify edge function can serve raw markdown via content negotiation. +// Frontmatter is rewritten to strip internal fields and add canonical_url; +// repo docs also get source_url and version. Relative and absolute /docs/ +// links are resolved to full https://prometheus.io URLs. A footer with the +// canonical URL is appended so consumers can find the origin without parsing +// frontmatter. +import docsCollectionJson from "../generated/docs-collection.json" with { type: "json" }; +import type { DocsCollection } from "@/docs-collection-types"; +import fs from "fs/promises"; +import path from "path"; +import matter from "gray-matter"; + +const BASE_URL = "https://prometheus.io"; +const collection = docsCollectionJson as DocsCollection; + +// Build a reverse map: resolved absolute filePath → slug. +const fileToSlug = new Map(); +for (const [slug, doc] of Object.entries(collection)) { + fileToSlug.set(path.resolve(doc.filePath), slug); +} + +function rewriteLinks(content: string, docFilePath: string): string { + const docDir = path.dirname(path.resolve(docFilePath)); + + // Match markdown links [text](url) but not images ![alt](url). + return content.replace(/(? { + const hashIdx = url.indexOf("#"); + const href = hashIdx === -1 ? url : url.slice(0, hashIdx); + const fragment = hashIdx === -1 ? "" : url.slice(hashIdx); + + // External links — leave as-is. + if (/^https?:\/\//.test(href)) return match; + + // Anchor-only links — leave as-is. + if (!href) return match; + + // Absolute /docs/ paths — prepend base URL. + if (href.startsWith("/docs/")) { + return `[${text}](${BASE_URL}${href}${fragment})`; + } + + // Other absolute paths — prepend base URL. + if (href.startsWith("/")) { + return `[${text}](${BASE_URL}${href}${fragment})`; + } + + // Relative links — resolve to a slug via the reverse map. + const resolved = path.resolve(docDir, href); + const slug = + fileToSlug.get(resolved) ?? + fileToSlug.get(resolved.replace(/\.md$/, "")) ?? + fileToSlug.get(resolved + ".md"); + + if (slug) { + return `[${text}](${BASE_URL}/docs/${slug}/${fragment})`; + } + + // Unresolvable relative link — leave as-is. + return match; + }); +} + +for (const [slug, doc] of Object.entries(collection)) { + const raw = await fs.readFile(doc.filePath, "utf-8"); + const { data, content } = matter(raw); + + const frontmatter: Record = { + title: data.title ?? doc.title, + canonical_url: `${BASE_URL}/docs/${slug}/`, + }; + + let outdatedNotice = ""; + + if (doc.type === "repo-doc") { + frontmatter.version = doc.version; + frontmatter.source_url = `https://github.com/${doc.owner}/${doc.repo}/blob/${doc.version}/docs/${path.basename(doc.filePath)}`; + + if (doc.version !== doc.latestVersion) { + // Replace the version segment in the slug to build the latest URL. + const latestSlug = slug.replace( + `${doc.slugPrefix}/${doc.version}/`, + `${doc.slugPrefix}/${doc.latestVersion}/` + ); + const latestUrl = `${BASE_URL}/docs/${latestSlug}/`; + frontmatter.outdated = true; + frontmatter.latest_version_url = latestUrl; + outdatedNotice = + `> **Note:** This page documents version ${doc.version}, which is outdated. ` + + `See the [latest stable version](${latestUrl}).\n\n`; + } + } + + const rewritten = rewriteLinks(content, doc.filePath); + const footer = `\n---\n\nSource: ${frontmatter.canonical_url}\n`; + const output = matter.stringify(outdatedNotice + rewritten, frontmatter) + footer; + + const dest = path.join("out/_md/docs", slug + ".md"); + await fs.mkdir(path.dirname(dest), { recursive: true }); + await fs.writeFile(dest, output); +} + +console.log(`Copied ${Object.keys(collection).length} markdown files to out/_md/docs/`); diff --git a/tsconfig.json b/tsconfig.json index fb670c16d..b427161bd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,5 @@ "noImplicitAny": false }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "netlify"] }