Skip to content

Commit 20416fc

Browse files
committed
docs: sync theme, fix downloads, expand guide content, other fixes
Syncs Scalar theme with Neon's dark/light toggle, points the OpenAPI download at the upstream spec, and adds a getting-started primer plus tag intros for the core resources. Empty tags now hidden.
1 parent b9052ca commit 20416fc

10 files changed

Lines changed: 439 additions & 30 deletions

File tree

src/app/(api-docs)/docs/api-reference-preview/ScalarMount.tsx

Lines changed: 128 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
'use client';
22

3-
import { useTheme } from 'next-themes';
43
import { useEffect, useRef } from 'react';
54

6-
// Pin to major version so patch/minor updates are picked up but breaking changes are not.
7-
// Update this when intentionally upgrading Scalar.
5+
// Pin to major so patch/minor updates flow but breaking changes don't.
6+
// Update when intentionally upgrading Scalar.
87
const CDN_URL = 'https://cdn.jsdelivr.net/npm/@scalar/api-reference@1';
98

10-
// Module-level promise: CDN loads once per page session regardless of React re-renders or
11-
// theme changes. Persists across HMR reloads in dev (acceptable).
9+
// Module-level: CDN loads once per page session regardless of remounts.
1210
let scalarCdnReady: Promise<void> | null = null;
1311

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

1715
scalarCdnReady = new Promise((resolve, reject) => {
18-
// Already loaded (e.g. back-navigation in SPA)
1916
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2017
if (typeof (window as any).Scalar?.createApiReference === 'function') {
2118
resolve();
@@ -34,11 +31,37 @@ function loadScalarCdn(): Promise<void> {
3431
return scalarCdnReady;
3532
}
3633

37-
function buildConfig(spec: string, darkMode: boolean) {
34+
// Factory for a Scalar fetch interceptor. When Scalar asks for `specUrl`, return our
35+
// pre-mutated spec. Any other URL (e.g. external $refs) falls through to real fetch.
36+
// New Response per call — Response bodies are one-shot and Scalar may re-read.
37+
function createSpecFetch(specUrl: string, specBody: string) {
38+
return async (url: string): Promise<Response> => {
39+
if (url === specUrl) {
40+
return new Response(specBody, {
41+
headers: { 'Content-Type': 'application/json' },
42+
});
43+
}
44+
return fetch(url);
45+
};
46+
}
47+
48+
function buildConfig(spec: string, specUrl: string, darkMode: boolean) {
3849
return {
39-
content: spec,
50+
// Pass `url` (not `content`) so Scalar's workspace store populates
51+
// document.x-scalar-original-source-url with specUrl — which is what the
52+
// "Download OpenAPI Document" link reads in `direct` mode. The interceptor
53+
// below makes Scalar fetch our mutated spec from that URL instead of the wire.
54+
url: specUrl,
55+
fetch: createSpecFetch(specUrl, spec),
4056
theme: 'default',
4157
darkMode,
58+
// Makes Scalar's initial body.dark-mode/.light-mode class match Neon's theme on
59+
// first mount. Scalar's useColorMode reads this once at init and is not reactive
60+
// to later updateConfiguration calls — subsequent theme changes are handled by
61+
// the MutationObserver + CSS keyed on html.dark.
62+
forceDarkModeState: darkMode ? 'dark' : 'light',
63+
// Neon's header is the single source of truth for theme.
64+
hideDarkModeToggle: true,
4265
agent: { disabled: true },
4366
mcp: { disabled: true },
4467
showDeveloperTools: 'never',
@@ -47,21 +70,43 @@ function buildConfig(spec: string, darkMode: boolean) {
4770
hideTestRequestButton: true,
4871
defaultOpenAllTags: true,
4972
defaultHttpClient: { targetKey: 'shell', clientKey: 'curl' },
73+
// 'direct' points the download button at specUrl itself (real, unmutated spec on
74+
// neon.com). Other values ('json'|'yaml'|'both') would serialize our in-memory
75+
// mutated spec and leak injected guide markdown into the downloaded file.
76+
documentDownloadType: 'direct',
5077
};
5178
}
5279

5380
const NEON_CSS = `
54-
/* --scalar-custom-header-height is Scalar's public variable for external header height.
55-
It feeds --refs-header-height which controls sidebar sticky top, sidebar height,
56-
and IntersectionObserver rootMargin. Must be set on :root. */
81+
/* Scalar's public var for external header offset. Feeds --refs-header-height which
82+
controls sidebar sticky top, sidebar height, and IntersectionObserver rootMargin. */
5783
:root {
5884
--scalar-custom-header-height: 112px;
5985
}
60-
/* Ensure anchor jumps and sidebar scrollIntoView land below the Neon header */
6186
html {
6287
scroll-padding-top: 112px;
6388
}
64-
#scalar-mount .dark-mode {
89+
/* Hide the right-column quickstart panel on the info block (server URL + Client
90+
Libraries snippet). It duplicates info we show elsewhere and has no selector
91+
meaning for us (single server, client tabs redirect back to operation snippets).
92+
These classes are scoped to the info block — per-operation snippets are unaffected. */
93+
#scalar-mount .scalar-reference-intro-server,
94+
#scalar-mount .scalar-reference-intro-clients,
95+
#scalar-mount .scalar-reference-intro-auth {
96+
display: none;
97+
}
98+
/* Theme is keyed on html.dark (next-themes) NOT Scalar's own .dark-mode/.light-mode.
99+
Scalar puts those on document.body (via useColorMode) and does not flip them when
100+
forceDarkModeState changes — so they're locked after init. html.dark is the only
101+
signal that reliably tracks Neon's toggle.
102+
103+
Sidebar vars are declared with literal values because Scalar's default preset sets
104+
e.g. --scalar-sidebar-background-1 to var(--scalar-background-1) at body.light-mode.
105+
That var() resolves at body to Scalar's default color, the resolved value inherits
106+
into the sidebar, and our #scalar-mount override never reaches it. */
107+
html.dark #scalar-mount,
108+
html.dark #scalar-mount .dark-mode,
109+
html.dark #scalar-mount .light-mode {
65110
--scalar-background-1: #0d0e12;
66111
--scalar-background-2: #131415;
67112
--scalar-background-3: #18191b;
@@ -73,8 +118,25 @@ const NEON_CSS = `
73118
--scalar-border-color: #242628;
74119
--scalar-font: 'IBM Plex Sans', sans-serif;
75120
--scalar-font-code: 'IBM Plex Mono', 'Fira Code', monospace;
121+
122+
--scalar-sidebar-background-1: #0d0e12;
123+
--scalar-sidebar-color-1: #e4e5e7;
124+
--scalar-sidebar-color-2: #afb1b6;
125+
--scalar-sidebar-border-color: #242628;
126+
--scalar-sidebar-item-hover-background: #131415;
127+
--scalar-sidebar-item-hover-color: #afb1b6;
128+
--scalar-sidebar-item-active-background: #131415;
129+
--scalar-sidebar-color-active: #e4e5e7;
130+
--scalar-sidebar-indent-border: #242628;
131+
--scalar-sidebar-indent-border-hover: #242628;
132+
--scalar-sidebar-indent-border-active: #242628;
133+
--scalar-sidebar-search-background: #131415;
134+
--scalar-sidebar-search-color: #797d86;
135+
--scalar-sidebar-search-border-color: #242628;
76136
}
77-
#scalar-mount .light-mode {
137+
html:not(.dark) #scalar-mount,
138+
html:not(.dark) #scalar-mount .dark-mode,
139+
html:not(.dark) #scalar-mount .light-mode {
78140
--scalar-background-1: #ffffff;
79141
--scalar-background-2: #f2f2f3;
80142
--scalar-background-3: #efeff0;
@@ -86,43 +148,80 @@ const NEON_CSS = `
86148
--scalar-border-color: #e4e5e7;
87149
--scalar-font: 'IBM Plex Sans', sans-serif;
88150
--scalar-font-code: 'IBM Plex Mono', 'Fira Code', monospace;
151+
152+
--scalar-sidebar-background-1: #ffffff;
153+
--scalar-sidebar-color-1: #0c0d0d;
154+
--scalar-sidebar-color-2: #494b50;
155+
--scalar-sidebar-border-color: #e4e5e7;
156+
--scalar-sidebar-item-hover-background: #f2f2f3;
157+
--scalar-sidebar-item-hover-color: #494b50;
158+
--scalar-sidebar-item-active-background: #f2f2f3;
159+
--scalar-sidebar-color-active: #0c0d0d;
160+
--scalar-sidebar-indent-border: #e4e5e7;
161+
--scalar-sidebar-indent-border-hover: #e4e5e7;
162+
--scalar-sidebar-indent-border-active: #e4e5e7;
163+
--scalar-sidebar-search-background: #f2f2f3;
164+
--scalar-sidebar-search-color: #797d86;
165+
--scalar-sidebar-search-border-color: #e4e5e7;
89166
}
90167
`;
91168

92-
export default function ScalarMount({ spec }: { spec: string }) {
93-
const { resolvedTheme } = useTheme();
169+
export default function ScalarMount({ spec, specUrl }: { spec: string; specUrl: string }) {
94170
const mountRef = useRef<HTMLDivElement>(null);
171+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
172+
const scalarInstanceRef = useRef<any>(null);
173+
const observerRef = useRef<MutationObserver | null>(null);
95174

96175
useEffect(() => {
97176
const mount = mountRef.current;
98177
if (!mount) return;
99178

100-
// Treat undefined (SSR/pre-hydration) as dark to match neon's default
101-
const darkMode = resolvedTheme !== 'light';
179+
let cancelled = false;
102180

103181
loadScalarCdn()
104182
.then(() => {
105-
if (!mount) return;
106-
// Clear previous Scalar instance before re-initializing
183+
if (cancelled || !mount) return;
184+
185+
const darkMode = document.documentElement.classList.contains('dark');
186+
187+
// Clear placeholder so Scalar uses createApp, not createSSRApp. Scalar checks
188+
// mountElement.children.length > 0 to decide — leaving our <p>Loading…</p>
189+
// triggers Vue hydration and a mismatch warning.
107190
while (mount.firstChild) mount.removeChild(mount.firstChild);
191+
108192
// eslint-disable-next-line @typescript-eslint/no-explicit-any
109-
(window as any).Scalar.createApiReference('#scalar-mount', buildConfig(spec, darkMode));
193+
scalarInstanceRef.current = (window as any).Scalar.createApiReference(
194+
mount,
195+
buildConfig(spec, specUrl, darkMode),
196+
);
197+
198+
// Watch html.class directly — this is what next-themes mutates, and firing on
199+
// it avoids the one-cycle lag of useTheme()/resolvedTheme.
200+
observerRef.current = new MutationObserver(() => {
201+
if (!scalarInstanceRef.current) return;
202+
const isDark = document.documentElement.classList.contains('dark');
203+
scalarInstanceRef.current.updateConfiguration(buildConfig(spec, specUrl, isDark));
204+
});
205+
observerRef.current.observe(document.documentElement, { attributeFilter: ['class'] });
110206
})
111207
.catch((err) => {
112-
console.error('Scalar failed to initialize:', err);
208+
if (!cancelled) console.error('[ScalarMount] init failed:', err);
113209
});
114-
}, [spec, resolvedTheme]);
210+
211+
return () => {
212+
cancelled = true;
213+
observerRef.current?.disconnect();
214+
observerRef.current = null;
215+
scalarInstanceRef.current?.destroy?.();
216+
scalarInstanceRef.current = null;
217+
};
218+
}, [spec, specUrl]);
115219

116220
return (
117221
<>
118222
{/* eslint-disable-next-line react/no-danger */}
119223
<style dangerouslySetInnerHTML={{ __html: NEON_CSS }} />
120-
<div
121-
ref={mountRef}
122-
id="scalar-mount"
123-
className="w-full"
124-
aria-label="API Reference"
125-
>
224+
<div ref={mountRef} id="scalar-mount" className="w-full" aria-label="API Reference">
126225
<p className="p-8 text-gray-new-50">Loading API reference…</p>
127226
</div>
128227
</>

src/app/(api-docs)/docs/api-reference-preview/page.tsx

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,50 @@
1+
import { readFile } from 'fs/promises';
2+
import path from 'path';
3+
14
import ScalarMount from './ScalarMount';
25

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

8+
// ---------------------------------------------------------------------------
9+
// Content injection config
10+
// ---------------------------------------------------------------------------
11+
// Markdown files live in src/content/api-docs/. Two extension points:
12+
//
13+
// SIDEBAR_GUIDES — standalone guide pages. Each becomes its own entry in
14+
// the sidebar under the "Guides" group (above the API reference). Order
15+
// here = order in the sidebar.
16+
//
17+
// TAG_INTROS — intro content rendered above the first operation of an
18+
// existing API tag. Key must match a spec tag name exactly (no-op on miss).
19+
//
20+
// To add content: drop a .md file in src/content/api-docs/ and add an entry
21+
// to the appropriate list.
22+
// ---------------------------------------------------------------------------
23+
24+
const SIDEBAR_GUIDES: Array<{ tagName: string; file: string }> = [
25+
{ tagName: 'Getting Started', file: 'getting-started.md' },
26+
];
27+
28+
const TAG_INTROS: Record<string, string> = {
29+
Auth: 'auth-intro.md',
30+
Project: 'project-intro.md',
31+
Branch: 'branch-intro.md',
32+
Endpoint: 'endpoint-intro.md',
33+
Operation: 'operation-intro.md',
34+
Consumption: 'consumption-intro.md',
35+
Snapshot: 'snapshot-intro.md',
36+
};
37+
38+
// Read a guide markdown file. Returns empty string on error so a missing file
39+
// degrades gracefully rather than breaking the page.
40+
async function readGuide(filename: string): Promise<string> {
41+
try {
42+
return await readFile(path.join(process.cwd(), 'src/content/api-docs', filename), 'utf8');
43+
} catch {
44+
return '';
45+
}
46+
}
47+
548
export default async function ApiReferencePage() {
649
let spec: Record<string, unknown>;
750

@@ -13,9 +56,12 @@ export default async function ApiReferencePage() {
1356
return <p className="p-8 text-gray-new-50">Failed to load API spec. Please try again later.</p>;
1457
}
1558

59+
// --- info-block cleanup ---
60+
1661
if (spec.info && typeof spec.info === 'object') {
1762
const info = spec.info as Record<string, unknown>;
1863
delete info.contact;
64+
delete info.license;
1965
if (typeof info.description === 'string') {
2066
info.description = info.description.replaceAll(
2167
'https://neon.tech/docs/',
@@ -24,5 +70,69 @@ export default async function ApiReferencePage() {
2470
}
2571
}
2672

27-
return <ScalarMount spec={JSON.stringify(spec)} />;
73+
// --- inject TAG_INTROS as descriptions on existing tags ---
74+
// Scalar renders tag descriptions as a content block above the tag's
75+
// operations, so this adds a rich intro without an extra sidebar entry.
76+
77+
if (Array.isArray(spec.tags)) {
78+
const tags = spec.tags as Array<{ name: string; description?: string }>;
79+
await Promise.all(
80+
Object.entries(TAG_INTROS).map(async ([tagName, file]) => {
81+
const content = await readGuide(file);
82+
if (!content) return;
83+
const tag = tags.find((t) => t.name === tagName);
84+
if (tag) tag.description = content;
85+
})
86+
);
87+
}
88+
89+
// --- inject SIDEBAR_GUIDES as synthetic tags so they appear in the sidebar ---
90+
// Tags with no operations but a description are rendered as pages by Scalar
91+
// (PR #7414, Nov 2025). "Introduction" is already auto-generated from
92+
// info.description — don't duplicate it here.
93+
94+
const guideTags = (
95+
await Promise.all(
96+
SIDEBAR_GUIDES.map(async ({ tagName, file }) => {
97+
const content = await readGuide(file);
98+
return content ? { name: tagName, description: content } : null;
99+
})
100+
)
101+
).filter((t): t is { name: string; description: string } => t !== null);
102+
103+
if (guideTags.length > 0) {
104+
const existingTags = Array.isArray(spec.tags) ? (spec.tags as Array<{ name: string }>) : [];
105+
spec.tags = [...guideTags, ...existingTags];
106+
107+
// Collect tags actually used by at least one operation so we can drop
108+
// empty-but-declared tags from the sidebar (e.g. "Preview" is defined in
109+
// the spec but currently has no operations, rendering as a blank page).
110+
const usedTagNames = new Set<string>();
111+
if (spec.paths && typeof spec.paths === 'object') {
112+
for (const pathItem of Object.values(spec.paths as Record<string, unknown>)) {
113+
if (!pathItem || typeof pathItem !== 'object') continue;
114+
for (const op of Object.values(pathItem as Record<string, unknown>)) {
115+
if (!op || typeof op !== 'object') continue;
116+
const opTags = (op as { tags?: unknown }).tags;
117+
if (!Array.isArray(opTags)) continue;
118+
for (const t of opTags) {
119+
if (typeof t === 'string') usedTagNames.add(t);
120+
}
121+
}
122+
}
123+
}
124+
125+
// x-tagGroups must list every tag explicitly — anything not listed is
126+
// hidden by Scalar. The "Guides" group pins our pages above the
127+
// alphabetically-sorted API tags.
128+
spec['x-tagGroups'] = [
129+
{ name: 'Guides', tags: guideTags.map((t) => t.name) },
130+
{
131+
name: 'API Reference',
132+
tags: existingTags.map((t) => t.name).filter((n) => usedTagNames.has(n)),
133+
},
134+
];
135+
}
136+
137+
return <ScalarMount spec={JSON.stringify(spec)} specUrl={SPEC_URL} />;
28138
}

src/content/api-docs/auth-intro.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
Neon Auth lets you add user authentication to your application backed by your Neon Postgres database. The endpoints below let you manage Neon Auth projects programmatically — provisioning auth for a Neon project, rotating keys, and inspecting state.
2+
3+
## When to use this API
4+
5+
Use the Neon Auth API when you need to automate auth setup as part of your deployment pipeline or internal tooling. If you're integrating authentication into an application, you'll typically use the [@neondatabase/auth](https://neon.com/docs/neon-auth) SDK instead, which talks to the service directly.
6+
7+
## Prerequisites
8+
9+
- A Neon project with the Auth feature enabled on your plan.
10+
- An API key with permission to manage the target project. See [Manage API keys](https://neon.com/docs/manage/api-keys) for how to create one.
11+
12+
All requests require a bearer token in the `Authorization` header:
13+
14+
```bash
15+
Authorization: Bearer $NEON_API_KEY
16+
```

0 commit comments

Comments
 (0)