11'use client' ;
22
3- import { useTheme } from 'next-themes' ;
43import { 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.
87const 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.
1210let scalarCdnReady : Promise < void > | null = null ;
1311
1412function 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
5380const 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 </ >
0 commit comments