@stainless-api/docs 0.1.0-beta.12 → 0.1.0-beta.120

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.
Files changed (152) hide show
  1. package/CHANGELOG.md +998 -0
  2. package/eslint-suppressions.json +95 -0
  3. package/{eslint.config.js → eslint.config.ts} +0 -2
  4. package/locals.d.ts +17 -0
  5. package/package.json +57 -43
  6. package/playground-virtual-modules.d.ts +96 -0
  7. package/plugin/assets/languages/cli.svg +14 -0
  8. package/plugin/assets/languages/csharp.svg +1 -0
  9. package/plugin/assets/languages/php.svg +4 -0
  10. package/plugin/buildAlgoliaIndex.ts +40 -39
  11. package/plugin/components/MethodDescription.tsx +54 -0
  12. package/plugin/components/RequestBuilder/ParamEditor.tsx +55 -0
  13. package/plugin/components/RequestBuilder/SnippetStainlessIsland.tsx +107 -0
  14. package/plugin/components/RequestBuilder/index.tsx +40 -0
  15. package/plugin/components/RequestBuilder/props.ts +9 -0
  16. package/plugin/components/RequestBuilder/spec-helpers.ts +47 -0
  17. package/plugin/components/RequestBuilder/styles.css +67 -0
  18. package/plugin/components/SDKSelect.astro +18 -111
  19. package/plugin/components/SnippetCode.tsx +112 -70
  20. package/plugin/components/StainlessIslands.tsx +126 -0
  21. package/plugin/components/search/SearchAlgolia.astro +46 -29
  22. package/plugin/components/search/SearchIsland.tsx +52 -29
  23. package/plugin/generateAPIReferenceLink.ts +2 -2
  24. package/plugin/globalJs/ai-dropdown-options.ts +248 -0
  25. package/plugin/globalJs/code-snippets.ts +45 -16
  26. package/plugin/globalJs/copy.ts +115 -27
  27. package/plugin/globalJs/create-playground.shim.ts +3 -0
  28. package/plugin/globalJs/method-descriptions.ts +33 -0
  29. package/plugin/globalJs/navigation.ts +15 -33
  30. package/plugin/globalJs/playground-data.shim.ts +1 -0
  31. package/plugin/globalJs/playground-data.ts +14 -0
  32. package/plugin/globalJs/summary-selection-tweak.ts +29 -0
  33. package/plugin/helpers/generateDocsRoutes.ts +59 -0
  34. package/plugin/helpers/multiSpec.ts +8 -0
  35. package/plugin/index.ts +306 -142
  36. package/plugin/languages.ts +8 -2
  37. package/plugin/loadPluginConfig.ts +251 -107
  38. package/plugin/middlewareBuilder/stainlessMiddleware.d.ts +3 -1
  39. package/plugin/react/Routing.tsx +214 -143
  40. package/plugin/referencePlaceholderUtils.ts +18 -15
  41. package/plugin/replaceSidebarPlaceholderMiddleware.ts +39 -35
  42. package/plugin/routes/Docs.astro +71 -111
  43. package/plugin/routes/DocsStatic.astro +6 -5
  44. package/plugin/routes/Overview.astro +46 -22
  45. package/plugin/routes/markdown.ts +13 -12
  46. package/plugin/{cms → sidebar-utils}/sidebar-builder.ts +83 -63
  47. package/plugin/specs/FileCache.ts +99 -0
  48. package/plugin/specs/fetchSpecSSR.ts +27 -0
  49. package/plugin/specs/generateSpec.ts +112 -0
  50. package/plugin/specs/index.ts +137 -0
  51. package/plugin/specs/inputResolver.ts +148 -0
  52. package/plugin/{cms → specs}/worker.ts +82 -5
  53. package/plugin/vendor/preview.worker.docs.js +27234 -17991
  54. package/plugin/vendor/templates/cli.md +1 -0
  55. package/plugin/vendor/templates/go.md +4 -2
  56. package/plugin/vendor/templates/java.md +5 -1
  57. package/plugin/vendor/templates/kotlin.md +5 -1
  58. package/plugin/vendor/templates/node.md +4 -2
  59. package/plugin/vendor/templates/python.md +4 -2
  60. package/plugin/vendor/templates/ruby.md +4 -2
  61. package/plugin/vendor/templates/terraform.md +1 -1
  62. package/plugin/vendor/templates/typescript.md +3 -1
  63. package/resolveSrcFile.ts +10 -0
  64. package/scripts/vendor_deps.ts +5 -5
  65. package/shared/conditionalIntegration.ts +28 -0
  66. package/shared/getProsePages.ts +41 -0
  67. package/shared/getSharedLogger.ts +15 -0
  68. package/shared/terminalUtils.ts +3 -0
  69. package/shared/virtualModule.ts +54 -1
  70. package/src/content.config.ts +9 -0
  71. package/stl-docs/components/AIDropdown.tsx +63 -0
  72. package/stl-docs/components/AiChatIsland.tsx +14 -0
  73. package/stl-docs/components/{content-panel/ContentBreadcrumbs.tsx → ContentBreadcrumbs.tsx} +2 -2
  74. package/stl-docs/components/ContentPanel.astro +9 -0
  75. package/stl-docs/components/Footer.astro +89 -0
  76. package/stl-docs/components/Head.astro +20 -0
  77. package/stl-docs/components/Header.astro +3 -10
  78. package/stl-docs/components/PageFrame.astro +34 -0
  79. package/stl-docs/components/PageSidebar.astro +11 -0
  80. package/stl-docs/components/PageTitle.astro +82 -0
  81. package/stl-docs/components/StainlessLogo.svg +4 -0
  82. package/stl-docs/components/TableOfContents.astro +34 -0
  83. package/stl-docs/components/ThemeProvider.astro +36 -0
  84. package/stl-docs/components/ThemeSelect.astro +84 -146
  85. package/stl-docs/components/TwoColumnContent.astro +2 -0
  86. package/stl-docs/components/headers/DefaultHeader.astro +4 -6
  87. package/stl-docs/components/headers/StackedHeader.astro +8 -51
  88. package/stl-docs/components/icons/chat-gpt.tsx +2 -2
  89. package/stl-docs/components/icons/cursor.tsx +10 -0
  90. package/stl-docs/components/icons/gemini.tsx +19 -0
  91. package/stl-docs/components/icons/markdown.tsx +1 -1
  92. package/stl-docs/components/index.ts +1 -0
  93. package/stl-docs/components/mintlify-compat/Frame.astro +4 -4
  94. package/stl-docs/components/mintlify-compat/card.css +4 -4
  95. package/stl-docs/components/mintlify-compat/index.ts +2 -4
  96. package/stl-docs/components/nav-tabs/NavDropdown.astro +31 -75
  97. package/stl-docs/components/nav-tabs/NavTabs.astro +79 -81
  98. package/stl-docs/components/nav-tabs/SecondaryNavTabs.astro +15 -7
  99. package/stl-docs/components/nav-tabs/buildNavLinks.ts +3 -2
  100. package/stl-docs/components/pagination/HomeLink.astro +10 -0
  101. package/stl-docs/components/pagination/Pagination.astro +177 -0
  102. package/stl-docs/components/pagination/PaginationLinkEmphasized.astro +22 -0
  103. package/stl-docs/components/pagination/PaginationLinkQuiet.astro +13 -0
  104. package/stl-docs/components/pagination/util.ts +71 -0
  105. package/stl-docs/components/scripts.ts +1 -0
  106. package/stl-docs/components/sidebars/BaseSidebar.astro +80 -2
  107. package/stl-docs/components/sidebars/SidebarWithComponents.tsx +10 -0
  108. package/stl-docs/components/sidebars/convertAstroSidebarToStl.tsx +62 -0
  109. package/stl-docs/disableCalloutSyntax.ts +36 -0
  110. package/stl-docs/fonts.ts +186 -0
  111. package/stl-docs/index.ts +169 -51
  112. package/stl-docs/loadStlDocsConfig.ts +64 -8
  113. package/stl-docs/proseDocSync.ts +314 -0
  114. package/stl-docs/proseMarkdown/proseMarkdownIntegration.ts +53 -0
  115. package/stl-docs/proseMarkdown/proseMarkdownMiddleware.ts +41 -0
  116. package/stl-docs/proseMarkdown/toMarkdown.ts +158 -0
  117. package/stl-docs/proseSearchIndexing.ts +222 -0
  118. package/stl-docs/tabsMiddleware.ts +14 -5
  119. package/styles/code.css +53 -49
  120. package/styles/links.css +2 -37
  121. package/styles/method-descriptions.css +36 -0
  122. package/styles/overrides.css +28 -46
  123. package/styles/page.css +230 -52
  124. package/styles/sdk_select.css +9 -6
  125. package/styles/search.css +11 -21
  126. package/styles/sidebar.css +28 -211
  127. package/styles/{variables.css → sl-variables.css} +4 -8
  128. package/styles/stldocs-variables.css +6 -0
  129. package/styles/toc.css +19 -8
  130. package/theme.css +11 -9
  131. package/tsconfig.json +1 -4
  132. package/virtual-module.d.ts +65 -8
  133. package/components/variables.css +0 -112
  134. package/plugin/cms/client.ts +0 -62
  135. package/plugin/cms/server.ts +0 -268
  136. package/plugin/globalJs/ai-dropdown.ts +0 -57
  137. package/stl-docs/components/APIReferenceAIDropdown.tsx +0 -58
  138. package/stl-docs/components/content-panel/ContentPanel.astro +0 -69
  139. package/stl-docs/components/content-panel/ProseAIDropdown.tsx +0 -55
  140. package/stl-docs/components/headers/SplashMobileMenuToggle.astro +0 -49
  141. package/stl-docs/components/mintlify-compat/Step.astro +0 -56
  142. package/stl-docs/components/mintlify-compat/Steps.astro +0 -15
  143. package/styles/fonts.css +0 -68
  144. /package/{plugin/assets → assets}/fonts/geist/OFL.txt +0 -0
  145. /package/{plugin/assets → assets}/fonts/geist/geist-italic-latin-ext.woff2 +0 -0
  146. /package/{plugin/assets → assets}/fonts/geist/geist-italic-latin.woff2 +0 -0
  147. /package/{plugin/assets → assets}/fonts/geist/geist-latin-ext.woff2 +0 -0
  148. /package/{plugin/assets → assets}/fonts/geist/geist-latin.woff2 +0 -0
  149. /package/{plugin/assets → assets}/fonts/geist/geist-mono-italic-latin-ext.woff2 +0 -0
  150. /package/{plugin/assets → assets}/fonts/geist/geist-mono-italic-latin.woff2 +0 -0
  151. /package/{plugin/assets → assets}/fonts/geist/geist-mono-latin-ext.woff2 +0 -0
  152. /package/{plugin/assets → assets}/fonts/geist/geist-mono-latin.woff2 +0 -0
@@ -1,6 +1,20 @@
1
- import type * as SDKJSON from '~/lib/json-spec-v2/types';
2
- import { generateRoute, walkTree, type DocsLanguage } from '@stainless-api/docs-ui/src/routing';
1
+ import type * as SDKJSON from '@stainless/sdk-json';
2
+ import { generateRoute, walkTree, type DocsLanguage } from '@stainless-api/docs-ui/routing';
3
3
  import type { StarlightRouteData } from '@astrojs/starlight/route-data';
4
+ import { isResourceEntirelyUnsupported } from '@stainless-api/docs-ui/languages/terraform';
5
+
6
+ function makeMethodOrResourceKey(entry: SDKJSON.Method | SDKJSON.Resource): string {
7
+ if (entry.kind === 'http_method') {
8
+ if (entry.endpoint && entry.endpoint !== '') {
9
+ return entry.endpoint;
10
+ }
11
+ return entry.stainlessPath;
12
+ }
13
+ if (entry.kind === 'resource') {
14
+ return entry.stainlessPath;
15
+ }
16
+ throw new Error(`Unknown entry kind ${JSON.stringify(entry)}`);
17
+ }
4
18
 
5
19
  function isResourceNonEmpty(resource: SDKJSON.Resource) {
6
20
  return (
@@ -119,23 +133,12 @@ export type GeneratedSidebarConfig = {
119
133
  ) => ReferenceSidebarConfigItem[] | void;
120
134
  };
121
135
 
122
- function countKeys(obj?: Record<string, any>) {
123
- let o = obj ?? {};
124
- return Object.keys(o).length;
125
- }
126
-
127
- function getMethodDeclForLanguage(entry: SDKJSON.Method, spec: SDKJSON.Spec, language: DocsLanguage) {
128
- const decls = spec.decls[language] ?? {};
129
- const decl = decls[entry.stainlessPath];
130
- if (decl !== undefined) {
131
- if ('ident' in decl) {
132
- return decl;
133
- }
134
- }
135
- return null;
136
+ function countKeys(obj?: Record<string, unknown>) {
137
+ return Object.keys(obj ?? {}).length;
136
138
  }
137
139
 
138
- type MethodDecl = Exclude<ReturnType<typeof getMethodDeclForLanguage>, null>;
140
+ type HasIdent<T> = T extends { ident: unknown } ? T : never;
141
+ type MethodDecl = HasIdent<SDKJSON.LanguageDeclNodes[SDKJSON.SpecLanguage]>;
139
142
 
140
143
  function makeAPIOverviewPage(): UserSidebarAPIOverviewPage {
141
144
  return {
@@ -166,7 +169,7 @@ function pullOutSharedModelsResource(resources: SDKJSON.Resource[]): {
166
169
  }
167
170
 
168
171
  export class SidebarConfigItemsBuilder {
169
- private getMethodDeclForLanguage(entry: SDKJSON.Method) {
172
+ private getMethodDeclForLanguage(entry: SDKJSON.Method): MethodDecl | null {
170
173
  const decls = this.spec.decls[this.language] ?? {};
171
174
  const decl = decls[entry.stainlessPath];
172
175
  if (decl !== undefined) {
@@ -177,11 +180,15 @@ export class SidebarConfigItemsBuilder {
177
180
  return null;
178
181
  }
179
182
 
183
+ private isWebhookResource(resource: SDKJSON.Resource): boolean {
184
+ return resource.stainlessPath === '(resource) webhooks';
185
+ }
186
+
180
187
  private toResourceOverviewPage(entry: SDKJSON.Resource): UserSidebarResourceOverviewPage {
181
188
  return {
182
189
  kind: 'resource_overview_page',
183
- label: 'Overview',
184
- key: entry.configRef,
190
+ label: this.isWebhookResource(entry) ? 'Events' : 'Overview',
191
+ key: makeMethodOrResourceKey(entry),
185
192
  badge: undefined,
186
193
  metadata: {
187
194
  subResourceCount: countKeys(entry.subresources),
@@ -195,7 +202,7 @@ export class SidebarConfigItemsBuilder {
195
202
  return {
196
203
  kind: 'method_page',
197
204
  label: entry.title,
198
- key: entry.endpoint,
205
+ key: makeMethodOrResourceKey(entry),
199
206
  badge: undefined,
200
207
  metadata: {
201
208
  deprecated: Boolean(entry.deprecated),
@@ -208,16 +215,10 @@ export class SidebarConfigItemsBuilder {
208
215
  };
209
216
  }
210
217
 
211
- private sortByLabel<T extends UserSidebarConfigItem>(items: T[]) {
212
- // sorts in place
213
- items.sort((a, b) => {
214
- return a.label.localeCompare(b.label);
215
- });
216
- }
217
-
218
218
  private generateResourceGroup(resource: SDKJSON.Resource, collapsed: boolean): ReferenceSidebarGroup {
219
219
  const entries: ReferenceSidebarConfigItem[] = [];
220
- if (!this.options?.excludeResourceOverviewPages) {
220
+ // even if we aren't generating resource overview pages, we want to generate them for webhooks
221
+ if (!this.options?.excludeResourceOverviewPages || this.isWebhookResource(resource)) {
221
222
  entries.push(this.toResourceOverviewPage(resource));
222
223
  }
223
224
  const methods = Object.values(resource.methods ?? {});
@@ -228,7 +229,6 @@ export class SidebarConfigItemsBuilder {
228
229
  methodPages.push(this.toMethodPage(m, langDecl));
229
230
  }
230
231
  }
231
- this.sortByLabel(methodPages);
232
232
  entries.push(...methodPages);
233
233
 
234
234
  const subresources = Object.values(resource.subresources ?? {});
@@ -238,26 +238,55 @@ export class SidebarConfigItemsBuilder {
238
238
  subresourceGroups.push(this.generateResourceGroup(sub, true));
239
239
  }
240
240
  }
241
- this.sortByLabel(subresourceGroups);
242
241
  entries.push(...subresourceGroups);
243
242
 
244
243
  return {
245
244
  kind: 'group',
246
245
  badge: undefined,
247
246
  label: resource.title,
248
- resourceGroupKey: resource.configRef,
247
+ resourceGroupKey: makeMethodOrResourceKey(resource),
249
248
  entries,
250
249
  collapsed,
251
250
  };
252
251
  }
253
252
 
253
+ private generateTerraformItems(resources: SDKJSON.Resource[]) {
254
+ const entries: ReferenceSidebarConfigItem[] = [];
255
+
256
+ for (const resource of resources) {
257
+ if (isResourceEntirelyUnsupported(resource, this.spec.decls['terraform'])) {
258
+ continue;
259
+ }
260
+ entries.push({
261
+ kind: 'resource_overview_page',
262
+ label: resource.title,
263
+ key: makeMethodOrResourceKey(resource),
264
+ badge: undefined,
265
+ metadata: {
266
+ subResourceCount: countKeys(resource.subresources),
267
+ methodCount: countKeys(resource.methods),
268
+ modelCount: countKeys(resource.models),
269
+ },
270
+ });
271
+ }
272
+ return entries;
273
+ }
274
+
254
275
  public generateItems(): ReferenceSidebarConfigItem[] {
255
276
  const resourceMap = this.spec.resources;
256
- let { resources, sharedModelsResource } = pullOutSharedModelsResource(Object.values(resourceMap ?? {}));
277
+ const { resources, sharedModelsResource } = pullOutSharedModelsResource(Object.values(resourceMap ?? {}));
257
278
 
258
- const entries: ReferenceSidebarConfigItem[] = resources.filter(isResourceNonEmpty).map((r) => {
259
- return this.generateResourceGroup(r, false);
260
- });
279
+ let entries: ReferenceSidebarConfigItem[];
280
+
281
+ if (this.language === 'terraform') {
282
+ // Handle Terraform specifically
283
+ // In TF, we only render the top level resource, not the subresources.
284
+ entries = this.generateTerraformItems(resources);
285
+ } else {
286
+ entries = resources.filter(isResourceNonEmpty).map((r) => {
287
+ return this.generateResourceGroup(r, false);
288
+ });
289
+ }
261
290
 
262
291
  const includeSharedModels = this.options?.includeSharedModels ?? false;
263
292
  if (includeSharedModels && sharedModelsResource) {
@@ -276,35 +305,24 @@ export class SidebarConfigItemsBuilder {
276
305
  ) {}
277
306
  }
278
307
 
279
- export function walkSidebarConfigItems(
280
- sidebar: ReferenceSidebarConfigItem[],
281
- fn: (item: ReferenceSidebarConfigItem) => void,
282
- ) {
283
- for (const item of sidebar) {
284
- fn(item);
285
- if (item.kind === 'group') {
286
- walkSidebarConfigItems(item.entries, fn);
287
- }
288
- }
289
- }
290
-
291
308
  type SidebarEntry = StarlightRouteData['sidebar'][number];
292
309
 
293
310
  // This allows us to be a bit more forgiving to the user.
294
311
  // As long as they don't modify the key, we can still find the item.
312
+
295
313
  function getResourceOrMethod(spec: SDKJSON.Spec, endpointOrConfigRef: string) {
296
314
  for (const entry of walkTree(spec, false)) {
297
- if (entry.data.kind === 'resource' && entry.data.configRef === endpointOrConfigRef) {
298
- return entry;
315
+ if (entry.data.kind === 'model') {
316
+ continue;
299
317
  }
300
- if (entry.data.kind === 'http_method' && entry.data.endpoint === endpointOrConfigRef) {
318
+ if (makeMethodOrResourceKey(entry.data) === endpointOrConfigRef) {
301
319
  return entry;
302
320
  }
303
321
  }
304
322
  return null;
305
323
  }
306
324
 
307
- export function forceGenerateRoute({
325
+ function forceGenerateRoute({
308
326
  basePath,
309
327
  stainlessPath,
310
328
  language,
@@ -325,19 +343,17 @@ export type BuildSidebarParams = {
325
343
  currentSlug: string;
326
344
  };
327
345
 
328
- type ToStarlightSidebarParams = BuildSidebarParams & {
346
+ type ToStarlightSidebarParams = {
347
+ basePath: string;
329
348
  spec: SDKJSON.Spec;
330
349
  entries: ReferenceSidebarConfigItem[];
331
- currentStainlessPath: string;
332
350
  currentLanguage: DocsLanguage;
333
351
  };
334
352
 
335
353
  export function toStarlightSidebar({
336
354
  basePath,
337
- currentSlug,
338
355
  spec,
339
356
  entries,
340
- currentStainlessPath,
341
357
  currentLanguage,
342
358
  }: ToStarlightSidebarParams): SidebarEntry[] {
343
359
  const starlightEntries: SidebarEntry[] = [];
@@ -350,7 +366,7 @@ export function toStarlightSidebar({
350
366
  type: 'link',
351
367
  href: readmeSlug,
352
368
  label: entry.label,
353
- isCurrent: currentSlug === readmeSlug,
369
+ isCurrent: false,
354
370
  badge: entry.badge,
355
371
  attrs: {
356
372
  'data-stldocs-overview': 'readme',
@@ -369,14 +385,12 @@ export function toStarlightSidebar({
369
385
  language: currentLanguage,
370
386
  });
371
387
 
372
- const isCurrent = resourceOrMethod.data.stainlessPath === currentStainlessPath;
373
-
374
388
  if (resourceOrMethod.data.kind === 'http_method') {
375
389
  starlightEntries.push({
376
390
  type: 'link',
377
391
  href: route,
378
392
  label: entry.label,
379
- isCurrent,
393
+ isCurrent: false,
380
394
  badge: entry.badge,
381
395
  attrs: {
382
396
  'data-stldocs-method': resourceOrMethod.data.httpMethod,
@@ -387,7 +401,7 @@ export function toStarlightSidebar({
387
401
  type: 'link',
388
402
  href: route,
389
403
  label: entry.label,
390
- isCurrent,
404
+ isCurrent: false,
391
405
  badge: entry.badge,
392
406
  attrs: {
393
407
  // TODO: @Ryan: is data.name unique? This is what we used before so I'm not changing, but I am curious.
@@ -398,14 +412,20 @@ export function toStarlightSidebar({
398
412
  throw new Error(`Unknown entry kind ${JSON.stringify(entry)}`);
399
413
  }
400
414
  } else if (entry.kind === 'group') {
415
+ if (entry.resourceGroupKey) {
416
+ const resourceOrMethod = getResourceOrMethod(spec, entry.resourceGroupKey);
417
+ // Skip pushing the group if if the resource it represents is not available in the current language.
418
+ // This occurs when SDK generation for the current language is skipped in the Stainless config for that resource.
419
+ if (resourceOrMethod?.data?.kind === 'resource' && !resourceOrMethod?.data?.[currentLanguage]) {
420
+ continue;
421
+ }
422
+ }
401
423
  starlightEntries.push({
402
424
  type: 'group',
403
425
  label: entry.label,
404
426
  entries: toStarlightSidebar({
405
427
  basePath,
406
- currentSlug,
407
428
  spec,
408
- currentStainlessPath,
409
429
  entries: entry.entries,
410
430
  currentLanguage,
411
431
  }),
@@ -0,0 +1,99 @@
1
+ import crypto from 'crypto';
2
+ import path from 'path';
3
+ import { readdir, readFile, rm, writeFile } from 'fs/promises';
4
+
5
+ type CacheResultSource = 'memory' | 'disk' | 'generation';
6
+
7
+ export type CacheGetResult<T> = {
8
+ resultSource: CacheResultSource;
9
+ data: T;
10
+ filePath: string;
11
+ };
12
+
13
+ export class FileCache<Inputs extends Record<string, unknown>, Output> {
14
+ private memoryCache: Map<string, Output> = new Map();
15
+
16
+ private cacheDirectory: string | null = null;
17
+
18
+ public setCacheDirectory(cacheDirectory: string) {
19
+ this.cacheDirectory = cacheDirectory;
20
+ }
21
+ public getCacheDirectory() {
22
+ if (!this.cacheDirectory) {
23
+ console.error(`Tried to retrieve entry from cache, but no cache directory was set.`);
24
+ process.exit(1);
25
+ }
26
+ return this.cacheDirectory;
27
+ }
28
+
29
+ private hashInputs(inputs: Inputs): string {
30
+ return crypto
31
+ .createHash('sha256')
32
+ .update(JSON.stringify(inputs) + this.config.globalHashBits)
33
+ .digest('hex')
34
+ .slice(0, 10);
35
+ }
36
+
37
+ private getFileName(hash: string) {
38
+ return `${hash}.json`;
39
+ }
40
+
41
+ public async cleanupUnusedFiles() {
42
+ const allFiles = await readdir(this.getCacheDirectory());
43
+ const usedFiles = Array.from(this.memoryCache.keys()).map((key) => this.getFileName(key));
44
+ const unusedFiles = allFiles.filter((file) => !usedFiles.includes(file));
45
+ await Promise.all(unusedFiles.map((file) => rm(path.join(this.getCacheDirectory(), file))));
46
+ return {
47
+ deletedCount: unusedFiles.length,
48
+ };
49
+ }
50
+
51
+ public async get(inputs: Inputs): Promise<CacheGetResult<Output>> {
52
+ const hash = this.hashInputs(inputs);
53
+ const filePath = path.join(this.getCacheDirectory(), this.getFileName(hash));
54
+
55
+ const memoryCacheResult = this.memoryCache.get(hash);
56
+ if (memoryCacheResult) {
57
+ return {
58
+ resultSource: 'memory',
59
+ data: memoryCacheResult,
60
+ filePath,
61
+ };
62
+ }
63
+
64
+ const getFromFileOrGenerate = async () => {
65
+ try {
66
+ const fileContents = await readFile(filePath, 'utf8');
67
+ return {
68
+ resultSource: 'disk' as const,
69
+ data: JSON.parse(fileContents) as Output,
70
+ filePath,
71
+ };
72
+ } catch {
73
+ const data = await this.config.generate(inputs);
74
+ await writeFile(filePath, JSON.stringify(data), 'utf8');
75
+ return {
76
+ resultSource: 'generation' as const,
77
+ data,
78
+ filePath,
79
+ };
80
+ }
81
+ };
82
+
83
+ const result = await getFromFileOrGenerate();
84
+ this.memoryCache.set(hash, result.data);
85
+ return result;
86
+ }
87
+
88
+ constructor(
89
+ private config: {
90
+ /**
91
+ * Additional information to include in the hash of the inputs.
92
+ * This is useful for cases where the inputs are the same, but the global state is different.
93
+ * Eg: The preview worker source can be used here.
94
+ */
95
+ globalHashBits: string;
96
+ generate: (inputs: Inputs) => Promise<Output>;
97
+ },
98
+ ) {}
99
+ }
@@ -0,0 +1,27 @@
1
+ import { readFile } from 'fs/promises';
2
+
3
+ import { api } from 'virtual:stainless-apis-manifest';
4
+ import { SpecWithAuth } from './generateSpec';
5
+ import { DocsLanguage } from '@stainless-api/docs-ui/routing';
6
+
7
+ const cachedSpecWithAuth: Record<string, SpecWithAuth> = {};
8
+
9
+ async function getSpecWithAuthInSSR(filePath: string) {
10
+ if (cachedSpecWithAuth[filePath]) {
11
+ return cachedSpecWithAuth[filePath];
12
+ }
13
+ const specStr = await readFile(filePath, 'utf8');
14
+ const json = JSON.parse(specStr) as SpecWithAuth;
15
+ cachedSpecWithAuth[filePath] = json;
16
+ return json;
17
+ }
18
+
19
+ export async function getSDKJSONInSSR(language: DocsLanguage) {
20
+ const filePath = api.languages.find((l) => l.language === language)?.sdkJSONFilePath;
21
+ if (!filePath) {
22
+ throw new Error(`No SDK JSON file path for language: ${language}`);
23
+ }
24
+
25
+ const specWithAuth = await getSpecWithAuthInSSR(filePath);
26
+ return specWithAuth.sdkJson;
27
+ }
@@ -0,0 +1,112 @@
1
+ import type * as SDKJSON from '@stainless/sdk-json';
2
+ import { Languages } from '@stainless-api/docs-ui/routing';
3
+ import { createSDKJSON, ParsedConfig, parseInputs, transformOAS } from './worker';
4
+ import { LanguageGenerateQuery } from '../loadPluginConfig';
5
+ import { FileCache } from './FileCache';
6
+ import previewWorkerCode from '../vendor/preview.worker.docs.js?raw';
7
+
8
+ function getLanguagesFromStainlessConfig(config: ParsedConfig): SDKJSON.SpecLanguage[] {
9
+ // if the Stainless config has a list of docs languages, use that
10
+ if (config.docs?.languages) {
11
+ return config.docs.languages;
12
+ }
13
+
14
+ // otherwise, just loop over all targets in the config + use the ones that are not skipped
15
+ return Object.entries(config.targets)
16
+ .filter(([name, target]) => {
17
+ if (!Languages.includes(name)) return false; // not a valid language
18
+ if (target.skip) return false; // config says to skip this language
19
+ return true;
20
+ })
21
+ .map(([name]) => name) as SDKJSON.SpecLanguage[];
22
+ }
23
+
24
+ // These inputs contain everything needed to generate a spec
25
+ // Combined with the source of the preview workers, we can make a hash to cache the resulting spec
26
+ export type GenerateSpecRawInputs = {
27
+ oasStr: string;
28
+ configStr: string;
29
+ languageOverrides: LanguageGenerateQuery | null;
30
+ stainlessProject: string;
31
+ versionInfo: Record<SDKJSON.SpecLanguage, string> | null;
32
+ };
33
+
34
+ function applyLanguageOverrides(
35
+ initialLanguages: SDKJSON.SpecLanguage[],
36
+ languageOverrides: LanguageGenerateQuery | null,
37
+ ) {
38
+ if (!languageOverrides) return initialLanguages;
39
+ if (languageOverrides.mode === 'exclude') {
40
+ return initialLanguages.filter((language) => !languageOverrides.list.includes(language));
41
+ }
42
+ return languageOverrides.list;
43
+ }
44
+
45
+ async function generateSpecFromStrings({
46
+ oasStr,
47
+ configStr,
48
+ stainlessProject,
49
+ languageOverrides,
50
+ versionInfo,
51
+ }: GenerateSpecRawInputs) {
52
+ const { oas, config } = await parseInputs({
53
+ oas: oasStr,
54
+ config: configStr,
55
+ });
56
+
57
+ const transformedOAS = await transformOAS({ oas, config });
58
+
59
+ let languagesToGenerate = getLanguagesFromStainlessConfig(config);
60
+ // by default, we should generate the HTTP spec (unless it's explicitly excluded)
61
+ if (!languagesToGenerate.includes('http')) {
62
+ languagesToGenerate.push('http');
63
+ }
64
+
65
+ languagesToGenerate = applyLanguageOverrides(languagesToGenerate, languageOverrides);
66
+
67
+ // SDKJSON has weird behavior where it will create a spec with HTTP, even if it's not in the languages list
68
+ const sdkJson = await createSDKJSON({
69
+ oas: transformedOAS,
70
+ config,
71
+ // if language overrides are provided, use them, otherwise use the languages from the Stainless config
72
+ languages: languagesToGenerate,
73
+ projectName: stainlessProject,
74
+ });
75
+
76
+ let languages = sdkJson.docs?.languages;
77
+
78
+ if (!languages) {
79
+ throw new Error(`SDKJSON created without any languages`);
80
+ }
81
+
82
+ // if language overrides are provided, filter the languages to only include the ones that are in the overrides
83
+ languages = languages.filter((language) => languagesToGenerate.includes(language));
84
+
85
+ if (versionInfo) {
86
+ for (const [lang, version] of Object.entries(versionInfo)) {
87
+ const meta = sdkJson.metadata[lang as SDKJSON.SpecLanguage];
88
+ if (meta?.version) meta.version = version;
89
+ }
90
+ }
91
+
92
+ const opts = Object.entries(config.client_settings.opts).map(([k, v]) => ({ name: k, ...v }));
93
+ return {
94
+ sdkJson,
95
+ languages,
96
+ auth: sdkJson.security_schemes.map((scheme) => ({
97
+ ...scheme,
98
+ opts: opts.filter((opt) => opt.auth?.security_scheme === scheme.name),
99
+ })),
100
+ };
101
+ }
102
+
103
+ export const specCache = new FileCache({
104
+ generate: generateSpecFromStrings,
105
+ globalHashBits: previewWorkerCode, // you can change this as a last resort to invalidate the cache
106
+ });
107
+
108
+ export type SpecCacheResult = Awaited<ReturnType<typeof specCache.get>>;
109
+
110
+ export type GenerateSpecFn = typeof generateSpecFromStrings;
111
+
112
+ export type SpecWithAuth = Awaited<ReturnType<GenerateSpecFn>>;
@@ -0,0 +1,137 @@
1
+ import { mkdir } from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ import type * as VirtualManifestModule from 'virtual:stainless-apis-manifest';
5
+
6
+ import { makeAsyncVirtualModPlugin } from '../../shared/virtualModule';
7
+
8
+ import { NormalizedStainlessStarlightConfig, ResolvedAPIConfigEntry } from '../loadPluginConfig';
9
+
10
+ import { specCache, SpecCacheResult } from './generateSpec';
11
+ import { AstroIntegrationLogger } from 'astro';
12
+ import type * as SDKJSON from '@stainless/sdk-json';
13
+
14
+ export type LoadedAPIConfigEntry = Omit<ResolvedAPIConfigEntry, 'loadSpecs'> & {
15
+ specs: SpecCacheResult[];
16
+ languages: SDKJSON.SpecLanguage[];
17
+ };
18
+
19
+ /**
20
+ * A helper class to manage multiple spec cache results for a single API
21
+ * An API may have multiple spec cache results if it has multiple languages
22
+ * Note that one spec may contain multiple languages.
23
+ * */
24
+ export class SpecComposite {
25
+ private languages: Set<SDKJSON.SpecLanguage>;
26
+ private readonly specs: Partial<Record<SDKJSON.SpecLanguage, SpecCacheResult>>;
27
+
28
+ public getLanguages() {
29
+ return Array.from(this.languages);
30
+ }
31
+
32
+ public getByLanguage(language: SDKJSON.SpecLanguage) {
33
+ const spec = this.specs[language];
34
+ if (!spec) {
35
+ throw new Error(`Spec for language ${language} not found`);
36
+ }
37
+ return spec;
38
+ }
39
+
40
+ /**
41
+ * Returns all specs. It will return each spec once, even if it has multiple languages.
42
+ * */
43
+ public listUniqueSpecs() {
44
+ const seen = new Set<SpecCacheResult>();
45
+ const unique: SpecCacheResult[] = [];
46
+ for (const spec of Object.values(this.specs)) {
47
+ if (!seen.has(spec)) {
48
+ seen.add(spec);
49
+ unique.push(spec);
50
+ }
51
+ }
52
+ return unique;
53
+ }
54
+
55
+ public listAllLanguagesAndIncludeSpecs() {
56
+ return this.getLanguages().map((language) => ({
57
+ language,
58
+ spec: this.getByLanguage(language),
59
+ }));
60
+ }
61
+
62
+ constructor(specs: SpecCacheResult[]) {
63
+ this.languages = new Set<SDKJSON.SpecLanguage>();
64
+ this.specs = {};
65
+ for (const spec of specs) {
66
+ for (const lang of spec.data.languages) {
67
+ if (this.languages.has(lang)) {
68
+ throw new Error(`Language appears multiple times in the same API: ${lang}`);
69
+ }
70
+ if (lang === 'openapi' || lang === 'sql') continue;
71
+ this.languages.add(lang);
72
+ this.specs[lang] = spec;
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ /** Runs once in the build process */
79
+ export async function startSpecLoader(
80
+ pluginConfig: NormalizedStainlessStarlightConfig,
81
+ logger: AstroIntegrationLogger,
82
+ codegenDir: URL,
83
+ ) {
84
+ const specsDirectory = path.join(codegenDir.pathname, 'specs');
85
+ await mkdir(specsDirectory, { recursive: true });
86
+
87
+ logger.debug(`Setting cache directory to ${specsDirectory}`);
88
+
89
+ // 🚨 Important! You cannot call loadSpecs() before setting the cache directory.
90
+ specCache.setCacheDirectory(specsDirectory);
91
+
92
+ async function load() {
93
+ const specs = await pluginConfig.api.loadSpecs();
94
+
95
+ // not awaited since it's just cleanup
96
+ specCache
97
+ .cleanupUnusedFiles()
98
+ .then((result) => {
99
+ if (result.deletedCount > 0) {
100
+ logger.info(`Cleaned up ${result.deletedCount} unused spec files`);
101
+ } else {
102
+ logger.debug(`No unused spec files to clean up`);
103
+ }
104
+ })
105
+ .catch(() => {
106
+ logger.warn(`Failed to clean up unused spec files`);
107
+ });
108
+
109
+ return {
110
+ specComposite: new SpecComposite(specs),
111
+ };
112
+ }
113
+
114
+ const specPromise = load();
115
+
116
+ return {
117
+ specPromise,
118
+ // this virtual module only resolves when the spec is generated
119
+ // this prevents the SSR module from trying to read the spec file before it's generated
120
+ vitePlugins: [
121
+ makeAsyncVirtualModPlugin<typeof VirtualManifestModule>('virtual:stainless-apis-manifest', async () => {
122
+ const api = await specPromise;
123
+
124
+ return {
125
+ api: {
126
+ languages: api.specComposite.listAllLanguagesAndIncludeSpecs().map((langSpec) => ({
127
+ language: langSpec.language,
128
+ sdkJSONFilePath: langSpec.spec.filePath,
129
+ })),
130
+ },
131
+ };
132
+ }),
133
+ ],
134
+ };
135
+ }
136
+
137
+ export type SpecLoader = Awaited<ReturnType<typeof startSpecLoader>>;