diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 000000000..d20297be3 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,11 @@ +[build.environment] +NODE_VERSION = "22" +NODE_OPTIONS = "--max-old-space-size=6144" + +[dev] + publish = "docs/" + framework = "#static" + +[[edge_functions]] +path = "/api/*" +function = "proxy-api-docs" diff --git a/netlify/edge-functions/proxy-api-docs.js b/netlify/edge-functions/proxy-api-docs.js new file mode 100644 index 000000000..e73f7441c --- /dev/null +++ b/netlify/edge-functions/proxy-api-docs.js @@ -0,0 +1,309 @@ +import { DOMParser } from "https://deno.land/x/deno_dom@v0.1.56/deno-dom-wasm.ts"; + +export default async (request, context) => { + const url = new URL(request.url); + const originalOrigin = url.origin; + + // Frame/partial requests (for example, ?partial=true) return HTML fragments, + // not the full document. They must never share a CDN cache entry with the + // root page, or a cached fragment gets served as the root document (and vice + // versa). Handled by the cache-control / Netlify-Vary headers on the HTML + // response below. + const isPartialRequest = url.searchParams.has("partial"); + + // Redirects from old API paths to new ones + const redirects = { + "/api/doc": "/api", + "/api/admin-api": "/api/doc/admin/", + "/api/http-proxy-api": "/api/doc/http-proxy/", + "/api/schema-registry-api": "/api/doc/schema-registry/", + "/api/cloud-controlplane-api": "/api/doc/cloud-controlplane/", + "/api/cloud-dataplane-api": "/api/doc/cloud-dataplane/", + "/api/cloud-api": "/api/doc/cloud-controlplane/", + }; + + const normalizedPath = url.pathname.endsWith("/") + ? url.pathname.slice(0, -1) + : url.pathname; + + if (redirects[normalizedPath]) { + return Response.redirect(`${url.origin}${redirects[normalizedPath]}`, 301); + } + + // Content negotiation: redirect to .md URL if markdown is explicitly requested + // Only match text/markdown per agent-friendly docs spec (text/plain is too broad) + const acceptHeader = request.headers.get('accept') || ''; + const wantsMarkdown = acceptHeader.includes('text/markdown'); + + if (wantsMarkdown && !url.pathname.endsWith('.md')) { + // Construct markdown URL - append .md to the path + const mdPath = normalizedPath + '.md'; + return Response.redirect(`${url.origin}${mdPath}`, 302); + } + + // Map paths to header background colors (8% component color mixed with white) + const headerColors = { + "/api/doc/admin": "color-mix(in srgb, #9F1239 8%, white)", // self-managed (rose) + "/api/doc/cloud-controlplane": "color-mix(in srgb, #1D4ED8 8%, white)", // cloud (blue) + "/api/doc/cloud-dataplane": "color-mix(in srgb, #1D4ED8 8%, white)", // cloud (blue) + }; + + const matchedPath = Object.keys(headerColors).find((path) => + normalizedPath.startsWith(path) + ); + const headerColor = headerColors[matchedPath] || "color-mix(in srgb, #1D4ED8 8%, white)"; // default to cloud + + // Build the proxied Bump.sh URL + const bumpUrl = new URL(request.url); + bumpUrl.host = "bump.sh"; + bumpUrl.pathname = `/redpanda/hub/redpanda${bumpUrl.pathname.replace("/api", "")}`; + + const secret = Netlify.env.get("BUMP_PROXY_SECRET"); + + // Validate secret exists + if (!secret) { + console.error("❌ BUMP_PROXY_SECRET environment variable not set"); + return new Response("Service temporarily unavailable", { + status: 503, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "public, max-age=60", + } + }); + } + + try { + const bumpRes = await fetchWithRetry(bumpUrl, { + headers: { + "X-BUMP-SH-PROXY": secret, + "X-BUMP-SH-EMBED": "true", + "User-Agent": "Redpanda-Docs-Proxy/1.0", + }, + }); + + // Handle non-successful responses + if (!bumpRes.ok) { + console.error(`❌ Bump.sh returned ${bumpRes.status}: ${bumpRes.statusText}`); + throw new Error(`Bump.sh API error: ${bumpRes.status}`); + } + + const contentType = bumpRes.headers.get("content-type") || ""; + + if (!contentType.includes("text/html")) { + // If requesting .md file, ensure correct content-type for markdown + if (url.pathname.endsWith('.md')) { + const body = await bumpRes.text(); + return new Response(body, { + status: bumpRes.status, + headers: { + "content-type": "text/markdown; charset=utf-8", + "cache-control": bumpRes.headers.get("cache-control") || "public, max-age=300", + }, + }); + } + return bumpRes; + } + + // Load Bump.sh page and widgets + const [ + originalHtml, + headScript, + headerWidget, + footerWidget, + chatPanelWidget, + ] = await Promise.all([ + bumpRes.text(), + fetchWidget(`${originalOrigin}/assets/widgets/head-bump.html`, "head-bump"), + fetchWidget(`${originalOrigin}/assets/widgets/header.html`, "header"), + fetchWidget(`${originalOrigin}/assets/widgets/footer.html`, "footer"), + fetchWidget(`${originalOrigin}/assets/widgets/chat-panel-bump.html`, "chat-panel"), + ]); + + let document; + try { + document = new DOMParser().parseFromString(originalHtml, "text/html"); + } catch (error) { + console.error("❌ Failed to initialize DOMParser (WASM issue):", error); + // Return unmodified HTML if DOM parsing fails + return new Response(originalHtml, { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + + if (!document) { + console.error("❌ Failed to parse Bump.sh HTML."); + return new Response(originalHtml, { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + + // Inject head script + const head = document.querySelector("head"); + if (head && headScript) { + const temp = document.createElement("div"); + temp.innerHTML = headScript; + for (const node of temp.childNodes) { + head.appendChild(node); + } + } + + // Inject header with dynamic background color + const topBody = document.querySelector("#embed-top-body"); + if (topBody && headerWidget) { + // Add background color to the navbar element + const coloredHeader = headerWidget.replace( + /]*class="[^"]*navbar[^"]*")/, + `