Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
229 changes: 229 additions & 0 deletions src/app/(api-docs)/docs/api-reference-preview/ScalarMount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
'use client';

import { useEffect, useRef } from 'react';

// Pin to major so patch/minor updates flow but breaking changes don't.
// Update when intentionally upgrading Scalar.
const CDN_URL = 'https://cdn.jsdelivr.net/npm/@scalar/api-reference@1';

// Module-level: CDN loads once per page session regardless of remounts.
let scalarCdnReady: Promise<void> | null = null;

function loadScalarCdn(): Promise<void> {
if (scalarCdnReady) return scalarCdnReady;

scalarCdnReady = new Promise((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (typeof (window as any).Scalar?.createApiReference === 'function') {
resolve();
return;
}
const script = document.createElement('script');
script.src = CDN_URL;
script.onload = () => resolve();
script.onerror = () => {
scalarCdnReady = null; // allow retry on next mount
reject(new Error('Failed to load Scalar CDN'));
};
document.head.appendChild(script);
});

return scalarCdnReady;
}

// Factory for a Scalar fetch interceptor. When Scalar asks for `specUrl`, return our
// pre-mutated spec. Any other URL (e.g. external $refs) falls through to real fetch.
// New Response per call — Response bodies are one-shot and Scalar may re-read.
function createSpecFetch(specUrl: string, specBody: string) {
return async (url: string): Promise<Response> => {
if (url === specUrl) {
return new Response(specBody, {
headers: { 'Content-Type': 'application/json' },
});
}
return fetch(url);
};
}

function buildConfig(spec: string, specUrl: string, darkMode: boolean) {
return {
// Pass `url` (not `content`) so Scalar's workspace store populates
// document.x-scalar-original-source-url with specUrl — which is what the
// "Download OpenAPI Document" link reads in `direct` mode. The interceptor
// below makes Scalar fetch our mutated spec from that URL instead of the wire.
url: specUrl,
fetch: createSpecFetch(specUrl, spec),
theme: 'default',
darkMode,
// Makes Scalar's initial body.dark-mode/.light-mode class match Neon's theme on
// first mount. Scalar's useColorMode reads this once at init and is not reactive
// to later updateConfiguration calls — subsequent theme changes are handled by
// the MutationObserver + CSS keyed on html.dark.
forceDarkModeState: darkMode ? 'dark' : 'light',
// Neon's header is the single source of truth for theme.
hideDarkModeToggle: true,
agent: { disabled: true },
mcp: { disabled: true },
showDeveloperTools: 'never',
hideModels: true,
hideClientButton: true,
hideTestRequestButton: true,
defaultOpenAllTags: true,
defaultHttpClient: { targetKey: 'shell', clientKey: 'curl' },
// 'direct' points the download button at specUrl itself (real, unmutated spec on
// neon.com). Other values ('json'|'yaml'|'both') would serialize our in-memory
// mutated spec and leak injected guide markdown into the downloaded file.
documentDownloadType: 'direct',
};
}

const NEON_CSS = `
/* Scalar's public var for external header offset. Feeds --refs-header-height which
controls sidebar sticky top, sidebar height, and IntersectionObserver rootMargin. */
:root {
--scalar-custom-header-height: 112px;
}
html {
scroll-padding-top: 112px;
}
/* Hide the right-column quickstart panel on the info block (server URL + Client
Libraries snippet). It duplicates info we show elsewhere and has no selector
meaning for us (single server, client tabs redirect back to operation snippets).
These classes are scoped to the info block — per-operation snippets are unaffected. */
#scalar-mount .scalar-reference-intro-server,
#scalar-mount .scalar-reference-intro-clients,
#scalar-mount .scalar-reference-intro-auth {
display: none;
}
/* Theme is keyed on html.dark (next-themes) NOT Scalar's own .dark-mode/.light-mode.
Scalar puts those on document.body (via useColorMode) and does not flip them when
forceDarkModeState changes — so they're locked after init. html.dark is the only
signal that reliably tracks Neon's toggle.

Sidebar vars are declared with literal values because Scalar's default preset sets
e.g. --scalar-sidebar-background-1 to var(--scalar-background-1) at body.light-mode.
That var() resolves at body to Scalar's default color, the resolved value inherits
into the sidebar, and our #scalar-mount override never reaches it. */
html.dark #scalar-mount,
html.dark #scalar-mount .dark-mode,
html.dark #scalar-mount .light-mode {
--scalar-background-1: #0d0e12;
--scalar-background-2: #131415;
--scalar-background-3: #18191b;
--scalar-background-accent: #00E59912;
--scalar-color-1: #e4e5e7;
--scalar-color-2: #afb1b6;
--scalar-color-3: #797d86;
--scalar-color-accent: #00E599;
--scalar-border-color: #242628;
--scalar-font: 'IBM Plex Sans', sans-serif;
--scalar-font-code: 'IBM Plex Mono', 'Fira Code', monospace;

--scalar-sidebar-background-1: #0d0e12;
--scalar-sidebar-color-1: #e4e5e7;
--scalar-sidebar-color-2: #afb1b6;
--scalar-sidebar-border-color: #242628;
--scalar-sidebar-item-hover-background: #131415;
--scalar-sidebar-item-hover-color: #afb1b6;
--scalar-sidebar-item-active-background: #131415;
--scalar-sidebar-color-active: #e4e5e7;
--scalar-sidebar-indent-border: #242628;
--scalar-sidebar-indent-border-hover: #242628;
--scalar-sidebar-indent-border-active: #242628;
--scalar-sidebar-search-background: #131415;
--scalar-sidebar-search-color: #797d86;
--scalar-sidebar-search-border-color: #242628;
}
html:not(.dark) #scalar-mount,
html:not(.dark) #scalar-mount .dark-mode,
html:not(.dark) #scalar-mount .light-mode {
--scalar-background-1: #ffffff;
--scalar-background-2: #f2f2f3;
--scalar-background-3: #efeff0;
--scalar-background-accent: #00E59912;
--scalar-color-1: #0c0d0d;
--scalar-color-2: #494b50;
--scalar-color-3: #797d86;
--scalar-color-accent: #00E599;
--scalar-border-color: #e4e5e7;
--scalar-font: 'IBM Plex Sans', sans-serif;
--scalar-font-code: 'IBM Plex Mono', 'Fira Code', monospace;

--scalar-sidebar-background-1: #ffffff;
--scalar-sidebar-color-1: #0c0d0d;
--scalar-sidebar-color-2: #494b50;
--scalar-sidebar-border-color: #e4e5e7;
--scalar-sidebar-item-hover-background: #f2f2f3;
--scalar-sidebar-item-hover-color: #494b50;
--scalar-sidebar-item-active-background: #f2f2f3;
--scalar-sidebar-color-active: #0c0d0d;
--scalar-sidebar-indent-border: #e4e5e7;
--scalar-sidebar-indent-border-hover: #e4e5e7;
--scalar-sidebar-indent-border-active: #e4e5e7;
--scalar-sidebar-search-background: #f2f2f3;
--scalar-sidebar-search-color: #797d86;
--scalar-sidebar-search-border-color: #e4e5e7;
}
`;

export default function ScalarMount({ spec, specUrl }: { spec: string; specUrl: string }) {
const mountRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const scalarInstanceRef = useRef<any>(null);
const observerRef = useRef<MutationObserver | null>(null);

useEffect(() => {
const mount = mountRef.current;
if (!mount) return;

let cancelled = false;

loadScalarCdn()
.then(() => {
if (cancelled || !mount) return;

const darkMode = document.documentElement.classList.contains('dark');

// Clear placeholder so Scalar uses createApp, not createSSRApp. Scalar checks
// mountElement.children.length > 0 to decide — leaving our <p>Loading…</p>
// triggers Vue hydration and a mismatch warning.
while (mount.firstChild) mount.removeChild(mount.firstChild);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
scalarInstanceRef.current = (window as any).Scalar.createApiReference(
mount,
buildConfig(spec, specUrl, darkMode),
);

// Watch html.class directly — this is what next-themes mutates, and firing on
// it avoids the one-cycle lag of useTheme()/resolvedTheme.
observerRef.current = new MutationObserver(() => {
if (!scalarInstanceRef.current) return;
const isDark = document.documentElement.classList.contains('dark');
scalarInstanceRef.current.updateConfiguration(buildConfig(spec, specUrl, isDark));
});
observerRef.current.observe(document.documentElement, { attributeFilter: ['class'] });
})
.catch((err) => {
if (!cancelled) console.error('[ScalarMount] init failed:', err);
});

return () => {
cancelled = true;
observerRef.current?.disconnect();
observerRef.current = null;
scalarInstanceRef.current?.destroy?.();
scalarInstanceRef.current = null;
};
}, [spec, specUrl]);

return (
<>
{/* eslint-disable-next-line react/no-danger */}
<style dangerouslySetInnerHTML={{ __html: NEON_CSS }} />
<div ref={mountRef} id="scalar-mount" className="w-full" aria-label="API Reference">
<p className="p-8 text-gray-new-50">Loading API reference…</p>
</div>
</>
);
}
138 changes: 138 additions & 0 deletions src/app/(api-docs)/docs/api-reference-preview/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { readFile } from 'fs/promises';
import path from 'path';

import ScalarMount from './ScalarMount';

const SPEC_URL = 'https://neon.com/api_spec/release/v2.json';

// ---------------------------------------------------------------------------
// Content injection config
// ---------------------------------------------------------------------------
// Markdown files live in src/content/api-docs/. Two extension points:
//
// SIDEBAR_GUIDES — standalone guide pages. Each becomes its own entry in
// the sidebar under the "Guides" group (above the API reference). Order
// here = order in the sidebar.
//
// TAG_INTROS — intro content rendered above the first operation of an
// existing API tag. Key must match a spec tag name exactly (no-op on miss).
//
// To add content: drop a .md file in src/content/api-docs/ and add an entry
// to the appropriate list.
// ---------------------------------------------------------------------------

const SIDEBAR_GUIDES: Array<{ tagName: string; file: string }> = [
{ tagName: 'Getting Started', file: 'getting-started.md' },
];

const TAG_INTROS: Record<string, string> = {
Auth: 'auth-intro.md',
Project: 'project-intro.md',
Branch: 'branch-intro.md',
Endpoint: 'endpoint-intro.md',
Operation: 'operation-intro.md',
Consumption: 'consumption-intro.md',
Snapshot: 'snapshot-intro.md',
};

// Read a guide markdown file. Returns empty string on error so a missing file
// degrades gracefully rather than breaking the page.
async function readGuide(filename: string): Promise<string> {
try {
return await readFile(path.join(process.cwd(), 'src/content/api-docs', filename), 'utf8');
} catch {
return '';
}
}

export default async function ApiReferencePage() {
let spec: Record<string, unknown>;

try {
const res = await fetch(SPEC_URL, { next: { revalidate: 300 } });
if (!res.ok) throw new Error(`Spec fetch failed: ${res.status}`);
spec = await res.json();
} catch {
return <p className="p-8 text-gray-new-50">Failed to load API spec. Please try again later.</p>;
}

// --- info-block cleanup ---

if (spec.info && typeof spec.info === 'object') {
const info = spec.info as Record<string, unknown>;
delete info.contact;
delete info.license;
if (typeof info.description === 'string') {
info.description = info.description.replaceAll(
'https://neon.tech/docs/',
'https://neon.com/docs/'
);
}
}

// --- inject TAG_INTROS as descriptions on existing tags ---
// Scalar renders tag descriptions as a content block above the tag's
// operations, so this adds a rich intro without an extra sidebar entry.

if (Array.isArray(spec.tags)) {
const tags = spec.tags as Array<{ name: string; description?: string }>;
await Promise.all(
Object.entries(TAG_INTROS).map(async ([tagName, file]) => {
const content = await readGuide(file);
if (!content) return;
const tag = tags.find((t) => t.name === tagName);
if (tag) tag.description = content;
})
);
}

// --- inject SIDEBAR_GUIDES as synthetic tags so they appear in the sidebar ---
// Tags with no operations but a description are rendered as pages by Scalar
// (PR #7414, Nov 2025). "Introduction" is already auto-generated from
// info.description — don't duplicate it here.

const guideTags = (
await Promise.all(
SIDEBAR_GUIDES.map(async ({ tagName, file }) => {
const content = await readGuide(file);
return content ? { name: tagName, description: content } : null;
})
)
).filter((t): t is { name: string; description: string } => t !== null);

if (guideTags.length > 0) {
const existingTags = Array.isArray(spec.tags) ? (spec.tags as Array<{ name: string }>) : [];
spec.tags = [...guideTags, ...existingTags];

// Collect tags actually used by at least one operation so we can drop
// empty-but-declared tags from the sidebar (e.g. "Preview" is defined in
// the spec but currently has no operations, rendering as a blank page).
const usedTagNames = new Set<string>();
if (spec.paths && typeof spec.paths === 'object') {
for (const pathItem of Object.values(spec.paths as Record<string, unknown>)) {
if (!pathItem || typeof pathItem !== 'object') continue;
for (const op of Object.values(pathItem as Record<string, unknown>)) {
if (!op || typeof op !== 'object') continue;
const opTags = (op as { tags?: unknown }).tags;
if (!Array.isArray(opTags)) continue;
for (const t of opTags) {
if (typeof t === 'string') usedTagNames.add(t);
}
}
}
}

// x-tagGroups must list every tag explicitly — anything not listed is
// hidden by Scalar. The "Guides" group pins our pages above the
// alphabetically-sorted API tags.
spec['x-tagGroups'] = [
{ name: 'Guides', tags: guideTags.map((t) => t.name) },
{
name: 'API Reference',
tags: existingTags.map((t) => t.name).filter((n) => usedTagNames.has(n)),
},
];
}

return <ScalarMount spec={JSON.stringify(spec)} specUrl={SPEC_URL} />;
}
21 changes: 21 additions & 0 deletions src/app/(api-docs)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react';
import Layout from 'components/shared/layout';
import { DOCS_BASE_PATH } from 'constants/docs';
import { getNavigation } from 'utils/api-docs';

export default async function ApiDocsLayout({ children }: { children: ReactNode }) {
const navigation = await getNavigation();

return (
<Layout
headerClassName="h-28"
docsNavigation={navigation}
docsBasePath={DOCS_BASE_PATH}
isDocPage
isHeaderSticky
hasThemesSupport
>
{children}
</Layout>
);
}
Loading