@stainless-api/docs 0.1.0-beta.102 → 0.1.0-beta.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,11 +23,6 @@ const shouldRenderSearch = !!(
23
23
 
24
24
  <style is:global>
25
25
  @layer starlight.core {
26
- header,
27
- header.header {
28
- border-color: var(--stl-color-border-faint);
29
- }
30
-
31
26
  .header {
32
27
  display: flex;
33
28
  gap: var(--sl-nav-gap);
@@ -1,18 +1,34 @@
1
1
  ---
2
- import Default from '@astrojs/starlight/components/PageFrame.astro';
2
+ import Sidebar from 'virtual:starlight/components/Sidebar';
3
+ import MobileMenuToggle from 'virtual:starlight/components/MobileMenuToggle';
4
+
3
5
  import AiChat from 'virtual:stl-docs/components/AiChat.tsx'; // conditionally resolves to null if ai chat module is not injected
4
6
  import AiChatIsland from './AiChatIsland.tsx'; // entrypoint for client island can’t be a virtual module
5
7
 
6
8
  import starlightConfig from 'virtual:starlight/user-config';
7
9
  const locale = Astro.currentLocale ?? starlightConfig.defaultLocale.lang;
8
10
  const siteTitle = locale && starlightConfig.title[locale];
11
+
12
+ const { hasSidebar } = Astro.locals.starlightRoute;
13
+ import clsx from 'clsx';
9
14
  ---
10
15
 
11
- <Default>
12
- <slot name="header" slot="header" />
13
- <slot name="sidebar" slot="sidebar" />
16
+ <div class="page">
17
+ <header>
18
+ <slot name="header" slot="header" />
19
+ </header>
20
+
21
+ <nav
22
+ class={clsx('sidebar', !hasSidebar && 'hidden')}
23
+ aria-label={Astro.locals.t('sidebarNav.accessibleLabel')}
24
+ >
25
+ <MobileMenuToggle />
26
+ <div id="starlight__sidebar" class="sidebar-pane">
27
+ <Sidebar />
28
+ </div>
29
+ </nav>
14
30
 
15
31
  <slot />
16
32
 
17
33
  {!!AiChat && <AiChatIsland client:load currentLanguage={Astro.locals.language} siteTitle={siteTitle} />}
18
- </Default>
34
+ </div>
@@ -0,0 +1,11 @@
1
+ ---
2
+ import TableOfContents from 'virtual:starlight/components/TableOfContents';
3
+ ---
4
+
5
+ {
6
+ Astro.locals.starlightRoute.toc && (
7
+ <div class="right-sidebar-panel">
8
+ <TableOfContents />
9
+ </div>
10
+ )
11
+ }
@@ -0,0 +1,2 @@
1
+ <slot />
2
+ <slot name="right-sidebar" slot="right-sidebar" />
@@ -1,42 +1,9 @@
1
- ---
2
-
3
- ---
4
-
5
- <div class="content-panel sl-container stl-ui-prose">
1
+ <div class="stl-ui-prose stl-content-panel">
6
2
  <slot />
7
3
  </div>
8
4
 
9
5
  <style>
10
- @layer starlight.core {
11
- .content-panel {
12
- padding: 1.5rem var(--sl-content-pad-x);
13
- }
14
- .sl-container {
15
- max-width: var(--sl-content-width);
16
- }
17
-
18
- @media (min-width: 72rem) {
19
- .sl-container {
20
- margin-inline: var(--sl-content-margin-inline, auto);
21
- }
22
- }
23
- }
24
-
25
- .content-panel {
26
- padding: 0 var(--sl-content-pad-x) 0 var(--sl-content-pad-x);
27
- }
28
-
29
- .content-panel + .content-panel {
6
+ .stl-content-panel + .stl-content-panel {
30
7
  margin-top: 24px;
31
8
  }
32
-
33
- @media (min-width: 50rem) {
34
- .content-panel {
35
- padding: 0 0 0 var(--sl-content-pad-x);
36
- }
37
- }
38
-
39
- .stl-prose-page-nav-container {
40
- padding: 1rem 0 0;
41
- }
42
9
  </style>
@@ -6,17 +6,15 @@ import HeaderLinks from './HeaderLinks.astro';
6
6
  import NavLinks from '../nav-tabs/NavTabs.astro';
7
7
  import { TABS } from 'virtual:stl-docs-virtual-module';
8
8
  import ThemeSelect from 'virtual:starlight/components/ThemeSelect';
9
- import SplashMobileMenuToggle from './SplashMobileMenuToggle.astro';
10
9
 
11
10
  interface Props {
12
11
  shouldRenderSearch?: boolean;
13
12
  }
14
13
 
15
14
  const { shouldRenderSearch } = Astro.props;
16
- const { hasSidebar } = Astro.locals.starlightRoute;
17
15
  ---
18
16
 
19
- <div class="header my-header">
17
+ <div class="header">
20
18
  <div class="left-group">
21
19
  <div class="title-wrapper sl-flex">
22
20
  <SiteTitle />
@@ -24,9 +22,9 @@ const { hasSidebar } = Astro.locals.starlightRoute;
24
22
  </div>
25
23
  {TABS.length > 0 && <NavLinks />}
26
24
 
27
- <div class={'sl-flex print:hidden right-group default-tabs-container'}>
28
- {shouldRenderSearch && <Search />}
29
- {!hasSidebar && <SplashMobileMenuToggle />}
25
+ {shouldRenderSearch && <Search />}
26
+
27
+ <div class="right-group">
30
28
  <div class="sl-hidden md:sl-flex">
31
29
  <ThemeSelect />
32
30
  <HeaderLinks />
@@ -3,32 +3,26 @@ import LanguageSelect from 'virtual:starlight/components/LanguageSelect';
3
3
  import Search from 'virtual:starlight/components/Search';
4
4
  import SiteTitle from 'virtual:starlight/components/SiteTitle';
5
5
  import HeaderLinks from './HeaderLinks.astro';
6
- import { TABS } from 'virtual:stl-docs-virtual-module';
7
6
  import ThemeSelect from 'virtual:starlight/components/ThemeSelect';
8
7
  import SecondaryNavTabs from '../nav-tabs/SecondaryNavTabs.astro';
9
- import SplashMobileMenuToggle from './SplashMobileMenuToggle.astro';
10
8
 
11
9
  interface Props {
12
10
  shouldRenderSearch?: boolean;
13
11
  }
14
12
 
15
13
  const { shouldRenderSearch } = Astro.props;
16
- const { hasSidebar } = Astro.locals.starlightRoute;
17
14
  ---
18
15
 
19
- <div class="header my-header">
16
+ <div class="header">
20
17
  <div class="left-group">
21
18
  <div class="title-wrapper sl-flex">
22
19
  <SiteTitle />
23
20
  </div>
24
21
  </div>
25
22
 
26
- <div class="stl-top-container">
27
- {shouldRenderSearch && <Search />}
28
- {!hasSidebar && <SplashMobileMenuToggle />}
29
- </div>
23
+ {shouldRenderSearch && <Search />}
30
24
 
31
- <div class={`sl-hidden md:sl-flex print:hidden right-group`}>
25
+ <div class="right-group">
32
26
  <ThemeSelect />
33
27
  <HeaderLinks />
34
28
  <LanguageSelect />
@@ -36,45 +30,3 @@ const { hasSidebar } = Astro.locals.starlightRoute;
36
30
  </div>
37
31
 
38
32
  <SecondaryNavTabs />
39
-
40
- <style is:inline>
41
- .stl-top-container {
42
- display: flex;
43
- flex-direction: row;
44
- align-items: center;
45
- justify-content: end;
46
- gap: 0.5rem;
47
- width: 100%;
48
- }
49
- @media (min-width: 50rem) {
50
- :root {
51
- --sl-nav-height: 88px;
52
- }
53
-
54
- header.header {
55
- display: flex;
56
- flex-direction: column;
57
- gap: 0.29rem;
58
- padding-bottom: 0;
59
- }
60
-
61
- .stl-top-container {
62
- justify-content: center;
63
- }
64
- }
65
- </style>
66
-
67
- {
68
- TABS.length === 0 && (
69
- <style
70
- is:inline
71
- set:text={`
72
- @media (min-width: 50rem) {
73
- :root {
74
- --sl-nav-height: 56px;
75
- }
76
- }
77
- `}
78
- />
79
- )
80
- }
@@ -90,8 +90,8 @@ const navLinks = buildNavLinks(Astro.locals.starlightRoute);
90
90
 
91
91
  localStorage.setItem('stl-nav-links-mode', mode);
92
92
 
93
- document.documentElement.classList.remove('stl-nav-links-mode-desktop', 'stl-nav-links-mode-mobile');
94
- document.documentElement.classList.add('stl-nav-links-mode-' + mode);
93
+ document.documentElement.classList.toggle('stl-nav-links-mode-desktop', mode === 'desktop');
94
+ document.documentElement.classList.toggle('stl-nav-links-mode-mobile', mode === 'mobile');
95
95
  }
96
96
 
97
97
  const resizeObserver = new ResizeObserver(() => {
package/stl-docs/index.ts CHANGED
@@ -21,7 +21,8 @@ import type * as StlDocsVirtualModule from 'virtual:stl-docs-virtual-module';
21
21
  import { resolveSrcFile } from '../resolveSrcFile';
22
22
  import { stainlessDocsMarkdownRenderer } from './proseMarkdown/proseMarkdownIntegration';
23
23
  import { setSharedLogger } from '../shared/getSharedLogger';
24
- import { stainlessDocsAlgoliaProseIndexing, stainlessDocsVectorProseIndexing } from './proseSearchIndexing';
24
+ import { stainlessDocsVectorProseIndexing } from './proseDocSync';
25
+ import { stainlessDocsAlgoliaProseIndexing } from './proseSearchIndexing';
25
26
  import { stainlessStarlight } from '../plugin';
26
27
  import { getFontRoles, flattenFonts } from './fonts';
27
28
 
@@ -50,6 +51,7 @@ function stainlessDocsStarlightIntegration(config: NormalizedStainlessDocsConfig
50
51
  type ComponentOverrides = StarlightConfigDefined['components'];
51
52
  const componentOverrides: ComponentOverrides = {
52
53
  PageFrame: resolveSrcFile(COMPONENTS_FOLDER, './PageFrame.astro'),
54
+ TwoColumnContent: resolveSrcFile(COMPONENTS_FOLDER, './TwoColumnContent.astro'),
53
55
 
54
56
  Head: resolveSrcFile(COMPONENTS_FOLDER, './Head.astro'),
55
57
  Header: resolveSrcFile(COMPONENTS_FOLDER, './Header.astro'),
@@ -58,6 +60,7 @@ function stainlessDocsStarlightIntegration(config: NormalizedStainlessDocsConfig
58
60
 
59
61
  Sidebar: resolveSrcFile(COMPONENTS_FOLDER, './sidebars/BaseSidebar.astro'),
60
62
  ContentPanel: resolveSrcFile(COMPONENTS_FOLDER, './content-panel/ContentPanel.astro'),
63
+ PageSidebar: resolveSrcFile(COMPONENTS_FOLDER, './PageSidebar.astro'),
61
64
  TableOfContents: resolveSrcFile(COMPONENTS_FOLDER, './TableOfContents.astro'),
62
65
 
63
66
  PageTitle: resolveSrcFile(COMPONENTS_FOLDER, './PageTitle.astro'),
@@ -0,0 +1,314 @@
1
+ import type { AstroIntegration, AstroIntegrationLogger } from 'astro';
2
+ import crypto from 'crypto';
3
+ import { readFile } from 'fs/promises';
4
+ import path from 'path';
5
+ import { getProsePages } from '../shared/getProsePages';
6
+ import { getSharedLogger } from '../shared/getSharedLogger';
7
+ import { bold } from '../shared/terminalUtils';
8
+ import { NormalizedStainlessDocsConfig } from './loadStlDocsConfig';
9
+
10
+ const DOCS_API_BASE_URL = 'https://api.stainlessapi.com';
11
+
12
+ // ─── API client ──────────────────────────────────────────────────────
13
+
14
+ async function docsApiRequest(
15
+ method: string,
16
+ apiPath: string,
17
+ apiKey: string,
18
+ body?: object,
19
+ ): Promise<Response> {
20
+ const headers: Record<string, string> = {
21
+ Authorization: `Bearer ${apiKey}`,
22
+ };
23
+ if (body) {
24
+ headers['Content-Type'] = 'application/json';
25
+ }
26
+ return fetch(`${DOCS_API_BASE_URL}${apiPath}`, {
27
+ method,
28
+ headers,
29
+ ...(body ? { body: JSON.stringify(body) } : {}),
30
+ });
31
+ }
32
+
33
+ // ─── Manifest & diffing ─────────────────────────────────────────────
34
+
35
+ type LocalDoc = { content: Buffer; sha256: string; source: string };
36
+
37
+ function sha256(content: Buffer): string {
38
+ return crypto.createHash('sha256').update(content).digest('hex');
39
+ }
40
+
41
+ async function buildLocalManifest(pages: string[], outputBasePath: string): Promise<Map<string, LocalDoc>> {
42
+ const docs = new Map<string, LocalDoc>();
43
+ for (const absHtmlPath of pages) {
44
+ const content = await readFile(absHtmlPath);
45
+ const docId = path.relative(outputBasePath, absHtmlPath);
46
+ docs.set(docId, { content, sha256: sha256(content), source: '/' + docId });
47
+ }
48
+ return docs;
49
+ }
50
+
51
+ async function fetchRemoteManifest(
52
+ docsSiteId: string,
53
+ project: string,
54
+ apiKey: string,
55
+ logger: AstroIntegrationLogger,
56
+ ): Promise<Map<string, string>> {
57
+ try {
58
+ const response = await docsApiRequest(
59
+ 'GET',
60
+ `/api/docs-sites/${docsSiteId}/documents?project=${encodeURIComponent(project)}`,
61
+ apiKey,
62
+ );
63
+
64
+ if (response.ok) {
65
+ const data = (await response.json()) as {
66
+ documents: { id: string; content_sha256: string }[];
67
+ };
68
+ return new Map(data.documents.map((d) => [d.id, d.content_sha256]));
69
+ }
70
+
71
+ logger.error(`Failed to list remote documents (HTTP ${response.status}): ${await response.text()}`);
72
+ } catch (err) {
73
+ logger.error(`Failed to list remote documents: ${err}`);
74
+ }
75
+ return new Map();
76
+ }
77
+
78
+ function diffManifests(
79
+ localDocs: Map<string, LocalDoc>,
80
+ remoteDocs: Map<string, string>,
81
+ ): { toPut: (LocalDoc & { docId: string })[]; toDelete: string[] } {
82
+ const toPut: (LocalDoc & { docId: string })[] = [];
83
+ for (const [docId, local] of localDocs) {
84
+ if (remoteDocs.get(docId) !== local.sha256) {
85
+ toPut.push({ docId, ...local });
86
+ }
87
+ }
88
+
89
+ const toDelete: string[] = [];
90
+ for (const remoteDocId of remoteDocs.keys()) {
91
+ if (!localDocs.has(remoteDocId)) {
92
+ toDelete.push(remoteDocId);
93
+ }
94
+ }
95
+
96
+ return { toPut, toDelete };
97
+ }
98
+
99
+ // ─── Import & delete ────────────────────────────────────────────────
100
+
101
+ type ImportJobResult = { succeeded: number; failed: number; errors: string[] };
102
+
103
+ async function importDocuments(
104
+ docs: (LocalDoc & { docId: string })[],
105
+ docsSiteId: string,
106
+ project: string,
107
+ apiKey: string,
108
+ logger: AstroIntegrationLogger,
109
+ ): Promise<ImportJobResult> {
110
+ const totals: ImportJobResult = { succeeded: 0, failed: 0, errors: [] };
111
+
112
+ for (let i = 0; i < docs.length; i += 100) {
113
+ const batch = docs.slice(i, i + 100);
114
+
115
+ let response: Response;
116
+ try {
117
+ response = await docsApiRequest('POST', `/api/docs-sites/${docsSiteId}/documents/import`, apiKey, {
118
+ project,
119
+ documents: batch.map(({ docId, content, source }) => ({
120
+ id: docId,
121
+ content: content.toString('utf-8'),
122
+ content_type: 'text/html',
123
+ source,
124
+ })),
125
+ });
126
+ } catch (err) {
127
+ logger.error(`Failed to submit import batch: ${err}`);
128
+ totals.failed += batch.length;
129
+ continue;
130
+ }
131
+
132
+ if (!response.ok) {
133
+ logger.error(`Failed to submit import batch (HTTP ${response.status}): ${await response.text()}`);
134
+ totals.failed += batch.length;
135
+ continue;
136
+ }
137
+
138
+ const { job_id } = (await response.json()) as { job_id: string };
139
+ const result = await pollImportJob(docsSiteId, job_id, apiKey, logger);
140
+ totals.succeeded += result.succeeded;
141
+ totals.failed += result.failed;
142
+ totals.errors.push(...result.errors);
143
+ }
144
+
145
+ return totals;
146
+ }
147
+
148
+ async function pollImportJob(
149
+ docsSiteId: string,
150
+ jobId: string,
151
+ apiKey: string,
152
+ logger: AstroIntegrationLogger,
153
+ ): Promise<ImportJobResult> {
154
+ const maxWait = 5 * 60_000;
155
+ const start = Date.now();
156
+
157
+ while (Date.now() - start < maxWait) {
158
+ await new Promise((r) => setTimeout(r, 2_000));
159
+
160
+ let response: Response;
161
+ try {
162
+ response = await docsApiRequest('GET', `/api/docs-sites/${docsSiteId}/documents/jobs/${jobId}`, apiKey);
163
+ } catch (err) {
164
+ logger.error(`Failed to poll import job ${jobId}: ${err}`);
165
+ continue;
166
+ }
167
+
168
+ if (!response.ok) {
169
+ logger.error(`Failed to poll import job ${jobId} (HTTP ${response.status}): ${await response.text()}`);
170
+ continue;
171
+ }
172
+
173
+ const job = (await response.json()) as {
174
+ status: string;
175
+ succeeded: number;
176
+ failed: number;
177
+ errors: string[] | null;
178
+ };
179
+
180
+ if (job.status === 'queued' || job.status === 'processing') continue;
181
+
182
+ return { succeeded: job.succeeded, failed: job.failed, errors: job.errors ?? [] };
183
+ }
184
+
185
+ logger.error(`Import job ${jobId} timed out after ${maxWait / 1000}s`);
186
+ return { succeeded: 0, failed: 0, errors: [`Job ${jobId} timed out`] };
187
+ }
188
+
189
+ async function deleteDocuments(
190
+ docIds: string[],
191
+ docsSiteId: string,
192
+ project: string,
193
+ apiKey: string,
194
+ logger: AstroIntegrationLogger,
195
+ ): Promise<{ succeeded: number; failed: number }> {
196
+ let succeeded = 0;
197
+ let failed = 0;
198
+
199
+ await Promise.all(
200
+ docIds.map(async (docId) => {
201
+ try {
202
+ const response = await docsApiRequest(
203
+ 'DELETE',
204
+ `/api/docs-sites/${docsSiteId}/documents/${encodeURIComponent(docId)}?project=${encodeURIComponent(project)}`,
205
+ apiKey,
206
+ );
207
+ if (response.ok) {
208
+ succeeded++;
209
+ } else {
210
+ logger.error(`Failed to delete ${docId} (HTTP ${response.status}): ${await response.text()}`);
211
+ failed++;
212
+ }
213
+ } catch (err) {
214
+ logger.error(`Failed to delete ${docId}: ${err}`);
215
+ failed++;
216
+ }
217
+ }),
218
+ );
219
+
220
+ return { succeeded, failed };
221
+ }
222
+
223
+ // ─── Sync orchestrator ──────────────────────────────────────────────
224
+
225
+ async function syncProseDocuments(opts: {
226
+ docsSiteId: string;
227
+ project: string;
228
+ apiKey: string;
229
+ pages: string[];
230
+ outputBasePath: string;
231
+ logger: AstroIntegrationLogger;
232
+ }) {
233
+ const { docsSiteId, project, apiKey, pages, outputBasePath, logger } = opts;
234
+
235
+ logger.info(bold(`Syncing ${pages.length} prose pages to docs search index`));
236
+
237
+ const localDocs = await buildLocalManifest(pages, outputBasePath);
238
+ const remoteDocs = await fetchRemoteManifest(docsSiteId, project, apiKey, logger);
239
+ const { toPut, toDelete } = diffManifests(localDocs, remoteDocs);
240
+
241
+ const unchanged = localDocs.size - toPut.length;
242
+ logger.info(bold(`${toPut.length} to upload, ${toDelete.length} to delete, ${unchanged} unchanged`));
243
+
244
+ if (toPut.length === 0 && toDelete.length === 0) {
245
+ logger.info('Docs search index is up to date');
246
+ return;
247
+ }
248
+
249
+ const uploaded =
250
+ toPut.length > 0
251
+ ? await importDocuments(toPut, docsSiteId, project, apiKey, logger)
252
+ : { succeeded: 0, failed: 0, errors: [] as string[] };
253
+
254
+ const deleted =
255
+ toDelete.length > 0
256
+ ? await deleteDocuments(toDelete, docsSiteId, project, apiKey, logger)
257
+ : { succeeded: 0, failed: 0 };
258
+
259
+ for (const err of uploaded.errors) {
260
+ logger.error(`Import error: ${err}`);
261
+ }
262
+
263
+ const failures = uploaded.failed + deleted.failed;
264
+ if (failures > 0) {
265
+ logger.error(
266
+ `Docs search index sync completed with ${failures} error(s): ${uploaded.succeeded} uploaded, ${deleted.succeeded} deleted`,
267
+ );
268
+ } else {
269
+ logger.info(
270
+ bold(`Docs search index synced: ${uploaded.succeeded} uploaded, ${deleted.succeeded} deleted`),
271
+ );
272
+ }
273
+ }
274
+
275
+ // ─── Astro integration ──────────────────────────────────────────────
276
+
277
+ export function stainlessDocsVectorProseIndexing(
278
+ config: NormalizedStainlessDocsConfig,
279
+ apiReferenceBasePath: string | null,
280
+ ): AstroIntegration {
281
+ return {
282
+ name: 'stl-docs-prose-indexing',
283
+ hooks: {
284
+ 'astro:build:done': async ({ logger: localLogger, dir }) => {
285
+ const logger = getSharedLogger({ fallback: localLogger });
286
+ const outputBasePath = dir.pathname;
287
+
288
+ const project = config.apiReference?.stainlessProject;
289
+ const { STAINLESS_API_KEY: apiKey, STAINLESS_DOCS_SITE_ID: docsSiteId } = process.env;
290
+
291
+ if (!apiKey || !project || !docsSiteId) {
292
+ logger.info(
293
+ `Skipping vector prose search indexing: required environment/config variables not set, missing: ${[
294
+ !apiKey && 'STAINLESS_API_KEY',
295
+ !docsSiteId && 'STAINLESS_DOCS_SITE_ID',
296
+ !project && 'stainlessProject in apiReference config',
297
+ ]
298
+ .filter(Boolean)
299
+ .join(', ')}`,
300
+ );
301
+ return;
302
+ }
303
+
304
+ const pages = await getProsePages({ apiReferenceBasePath, outputBasePath });
305
+ if (pages.length === 0) {
306
+ logger.info('No prose pages found to index for vector search');
307
+ return;
308
+ }
309
+
310
+ await syncProseDocuments({ docsSiteId, project, apiKey, pages, outputBasePath, logger });
311
+ },
312
+ },
313
+ };
314
+ }