@stainless-api/docs 0.1.0-beta.0

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 (119) hide show
  1. package/.env.example +1 -0
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +11 -0
  4. package/components/variables.css +139 -0
  5. package/eslint.config.js +10 -0
  6. package/package.json +74 -0
  7. package/plugin/assets/fonts/geist/OFL.txt +93 -0
  8. package/plugin/assets/fonts/geist/geist-italic-latin-ext.woff2 +0 -0
  9. package/plugin/assets/fonts/geist/geist-italic-latin.woff2 +0 -0
  10. package/plugin/assets/fonts/geist/geist-latin-ext.woff2 +0 -0
  11. package/plugin/assets/fonts/geist/geist-latin.woff2 +0 -0
  12. package/plugin/assets/fonts/geist/geist-mono-italic-latin-ext.woff2 +0 -0
  13. package/plugin/assets/fonts/geist/geist-mono-italic-latin.woff2 +0 -0
  14. package/plugin/assets/fonts/geist/geist-mono-latin-ext.woff2 +0 -0
  15. package/plugin/assets/fonts/geist/geist-mono-latin.woff2 +0 -0
  16. package/plugin/assets/languages/curl.svg +10 -0
  17. package/plugin/assets/languages/go.svg +4 -0
  18. package/plugin/assets/languages/java.svg +7 -0
  19. package/plugin/assets/languages/kotlin.svg +10 -0
  20. package/plugin/assets/languages/powershell.svg +3 -0
  21. package/plugin/assets/languages/python.svg +19 -0
  22. package/plugin/assets/languages/ruby.svg +125 -0
  23. package/plugin/assets/languages/terraform.svg +5 -0
  24. package/plugin/assets/languages/typescript.svg +11 -0
  25. package/plugin/assets/stainless-logo-dark.png +0 -0
  26. package/plugin/assets/stainless-logo.png +0 -0
  27. package/plugin/buildAlgoliaIndex.ts +72 -0
  28. package/plugin/cms/client.ts +62 -0
  29. package/plugin/cms/server.ts +268 -0
  30. package/plugin/cms/sidebar-builder.ts +420 -0
  31. package/plugin/cms/worker.ts +122 -0
  32. package/plugin/components/SDKSelect.astro +154 -0
  33. package/plugin/components/SnippetCode.tsx +212 -0
  34. package/plugin/components/search/Search.astro +6 -0
  35. package/plugin/components/search/SearchAlgolia.astro +87 -0
  36. package/plugin/components/search/SearchIsland.tsx +100 -0
  37. package/plugin/generateAPIReferenceLink.ts +71 -0
  38. package/plugin/globalJs/ai-dropdown.ts +57 -0
  39. package/plugin/globalJs/code-snippets.ts +87 -0
  40. package/plugin/globalJs/copy.ts +37 -0
  41. package/plugin/globalJs/navigation.ts +81 -0
  42. package/plugin/globalJs/tooltip.ts +32 -0
  43. package/plugin/helpers/getPageLoadEvent.ts +8 -0
  44. package/plugin/index.ts +308 -0
  45. package/plugin/languages.ts +67 -0
  46. package/plugin/loadPluginConfig.ts +273 -0
  47. package/plugin/middlewareBuilder/stainlessMiddleware.d.ts +5 -0
  48. package/plugin/middlewareBuilder/stlStarlightMiddleware.ts +5 -0
  49. package/plugin/react/Routing.tsx +435 -0
  50. package/plugin/referencePlaceholderUtils.ts +82 -0
  51. package/plugin/replaceSidebarPlaceholderMiddleware.ts +50 -0
  52. package/plugin/routes/Docs.astro +171 -0
  53. package/plugin/routes/DocsStatic.astro +14 -0
  54. package/plugin/routes/Overview.astro +67 -0
  55. package/plugin/routes/markdown.ts +58 -0
  56. package/plugin/vendor/preview.worker.docs.js +21657 -0
  57. package/plugin/vendor/templates/go.md +314 -0
  58. package/plugin/vendor/templates/java.md +87 -0
  59. package/plugin/vendor/templates/kotlin.md +87 -0
  60. package/plugin/vendor/templates/node.md +233 -0
  61. package/plugin/vendor/templates/python.md +249 -0
  62. package/plugin/vendor/templates/ruby.md +145 -0
  63. package/plugin/vendor/templates/terraform.md +60 -0
  64. package/plugin/vendor/templates/typescript.md +317 -0
  65. package/scripts/vendor_deps.ts +50 -0
  66. package/shared/virtualModule.ts +7 -0
  67. package/stl-docs/components/APIReferenceAIDropdown.tsx +86 -0
  68. package/stl-docs/components/ClientRouterHead.astro +41 -0
  69. package/stl-docs/components/Header.astro +91 -0
  70. package/stl-docs/components/Sidebar.astro +11 -0
  71. package/stl-docs/components/ThemeSelect.astro +225 -0
  72. package/stl-docs/components/content-panel/ContentBreadcrumbs.tsx +84 -0
  73. package/stl-docs/components/content-panel/ContentPanel.astro +72 -0
  74. package/stl-docs/components/content-panel/ProseAIDropdown.tsx +64 -0
  75. package/stl-docs/components/headers/DefaultHeader.astro +36 -0
  76. package/stl-docs/components/headers/HeaderLinks.astro +16 -0
  77. package/stl-docs/components/headers/SplashMobileMenuToggle.astro +49 -0
  78. package/stl-docs/components/headers/StackedHeader.astro +75 -0
  79. package/stl-docs/components/mintlify-compat/Accordion.astro +46 -0
  80. package/stl-docs/components/mintlify-compat/AccordionGroup.astro +25 -0
  81. package/stl-docs/components/mintlify-compat/Card.tsx +32 -0
  82. package/stl-docs/components/mintlify-compat/Columns.astro +66 -0
  83. package/stl-docs/components/mintlify-compat/Frame.astro +37 -0
  84. package/stl-docs/components/mintlify-compat/Step.astro +58 -0
  85. package/stl-docs/components/mintlify-compat/Steps.astro +17 -0
  86. package/stl-docs/components/mintlify-compat/Tab.astro +13 -0
  87. package/stl-docs/components/mintlify-compat/Tabs.astro +7 -0
  88. package/stl-docs/components/mintlify-compat/callouts/Callout.astro +7 -0
  89. package/stl-docs/components/mintlify-compat/callouts/Check.astro +7 -0
  90. package/stl-docs/components/mintlify-compat/callouts/Danger.astro +7 -0
  91. package/stl-docs/components/mintlify-compat/callouts/Info.astro +7 -0
  92. package/stl-docs/components/mintlify-compat/callouts/Note.astro +7 -0
  93. package/stl-docs/components/mintlify-compat/callouts/Tip.astro +7 -0
  94. package/stl-docs/components/mintlify-compat/callouts/Warning.astro +7 -0
  95. package/stl-docs/components/mintlify-compat/callouts/index.ts +9 -0
  96. package/stl-docs/components/mintlify-compat/card.css +44 -0
  97. package/stl-docs/components/mintlify-compat/index.ts +15 -0
  98. package/stl-docs/components/nav-tabs/NavDropdown.astro +106 -0
  99. package/stl-docs/components/nav-tabs/NavTabs.astro +165 -0
  100. package/stl-docs/components/nav-tabs/SecondaryNavTabs.astro +62 -0
  101. package/stl-docs/components/nav-tabs/buildNavLinks.ts +14 -0
  102. package/stl-docs/index.ts +174 -0
  103. package/stl-docs/loadStlDocsConfig.ts +160 -0
  104. package/stl-docs/redirects.ts +33 -0
  105. package/stl-docs/tabsMiddleware.ts +183 -0
  106. package/styles/code.css +189 -0
  107. package/styles/fonts.css +68 -0
  108. package/styles/links.css +51 -0
  109. package/styles/mintlify-compat.css +1 -0
  110. package/styles/overrides.css +79 -0
  111. package/styles/page.css +76 -0
  112. package/styles/sdk_select.css +11 -0
  113. package/styles/search.css +85 -0
  114. package/styles/sidebar.css +168 -0
  115. package/styles/toc.css +42 -0
  116. package/styles/variables.css +18 -0
  117. package/theme.css +15 -0
  118. package/tsconfig.json +18 -0
  119. package/virtual-module.d.ts +43 -0
@@ -0,0 +1,62 @@
1
+ ---
2
+ import { Button } from '@stainless-api/ui-primitives';
3
+ import { buildNavLinks } from './buildNavLinks';
4
+ import clsx from 'clsx';
5
+
6
+ const navLinks = buildNavLinks(Astro.locals.starlightRoute);
7
+ ---
8
+
9
+ <div class="stl-secondary-nav-tabs">
10
+ <ul>
11
+ {
12
+ navLinks.map((item) => (
13
+ <li class:list={[item.active && 'active']}>
14
+ <Button
15
+ href={item.link}
16
+ variant="ghost"
17
+ className={clsx(item.active && 'stl-active-secondary-link')}
18
+ >
19
+ <Button.Label>{item.label}</Button.Label>
20
+ </Button>
21
+ </li>
22
+ ))
23
+ }
24
+ </ul>
25
+ </div>
26
+
27
+ <style>
28
+ .stl-secondary-nav-tabs {
29
+ display: none;
30
+ width: 100%;
31
+
32
+ ul {
33
+ display: flex;
34
+ align-items: center;
35
+ padding: 0;
36
+ list-style: none;
37
+ overflow-x: auto;
38
+ margin-bottom: -1px;
39
+ gap: 0.29rem;
40
+ }
41
+
42
+ .stl-active-secondary-link:hover {
43
+ background-color: transparent;
44
+ }
45
+
46
+ li {
47
+ padding-bottom: 1px;
48
+ border-bottom: 2px solid transparent;
49
+
50
+ &.active {
51
+ border-color: var(--sl-color-text-accent);
52
+ }
53
+ }
54
+ }
55
+
56
+ @media (min-width: 50rem) {
57
+ .stl-secondary-nav-tabs {
58
+ display: block;
59
+ padding-left: 0.55rem;
60
+ }
61
+ }
62
+ </style>
@@ -0,0 +1,14 @@
1
+ import type { StarlightRouteData } from '@astrojs/starlight/route-data';
2
+ import { TABS } from 'virtual:stl-stl-starlight-virtual-module';
3
+
4
+ export function buildNavLinks(starlightRoute: StarlightRouteData) {
5
+ // TODO: specify the type of Astro.locals.starlightRoute._stlStarlight
6
+ const activeTabIndex = starlightRoute._stlStarlight?.activeTabIndex;
7
+
8
+ const navLinks = TABS.map((item, index) => ({
9
+ ...item,
10
+ active: index === activeTabIndex,
11
+ })).filter((item) => !item.hidden);
12
+
13
+ return navLinks;
14
+ }
@@ -0,0 +1,174 @@
1
+ import starlight from '@astrojs/starlight';
2
+ import react from '@astrojs/react';
3
+ import { stainlessStarlight } from '../plugin';
4
+
5
+ import type { AstroIntegration } from 'astro';
6
+
7
+ import { normalizeRedirects, type NormalizedRedirectConfig } from './redirects';
8
+ import { join } from 'path';
9
+ import { mkdirSync, writeFileSync } from 'fs';
10
+ import {
11
+ parseStlDocsConfig,
12
+ type Defined,
13
+ type NormalizedStainlessDocsConfig,
14
+ type StainlessDocsUserConfig,
15
+ type StarlightConfigDefined,
16
+ type StarlightSidebarConfig,
17
+ } from './loadStlDocsConfig';
18
+ import { buildVirtualModuleString } from '../shared/virtualModule';
19
+
20
+ import geistPath from '../plugin/assets/fonts/geist/geist-latin.woff2?url';
21
+
22
+ export * from '../plugin';
23
+
24
+ function stainlessDocsStarlightIntegration(config: NormalizedStainlessDocsConfig) {
25
+ // We transform our tabs into a Starlight sidebar
26
+ // This gives them all the built-in features of Starlight (eg. auto-generated entries by directory)
27
+ // In our middleware, we pull the appropriate group for the current tab and remove the others
28
+ let sidebar: Defined<StarlightSidebarConfig> = [];
29
+
30
+ if (config.splitTabsEnabled) {
31
+ config.tabs.forEach((tab, index) => {
32
+ // force unwrap bc we know we have a sidebar
33
+ sidebar.push({
34
+ label: index.toString(),
35
+ items: tab.sidebar ?? [],
36
+ });
37
+ });
38
+ } else if (config.sidebar) {
39
+ sidebar = config.sidebar;
40
+ }
41
+
42
+ type ComponentOverrides = StarlightConfigDefined['components'];
43
+ const componentOverrides: ComponentOverrides = {
44
+ Search: '@stainless-api/docs/Search',
45
+ Sidebar: '@stainless-api/docs/Sidebar',
46
+ Header: '@stainless-api/docs/Header',
47
+ ThemeSelect: '@stainless-api/docs/ThemeSelect',
48
+ ContentPanel: '@stainless-api/docs/ContentPanel',
49
+ };
50
+
51
+ // TODO: re-add once we figure out what to do with the client router
52
+ // if (config.enableClientRouter) {
53
+ // // logger.info(`Client router is enabled`);
54
+ // componentOverrides.Head = '@stainless-api/docs/head';
55
+ // } else {
56
+ // // logger.info(`Client router is disabled`);
57
+ // }
58
+
59
+ return starlight({
60
+ ...config.starlightPassThrough,
61
+ sidebar,
62
+ components: {
63
+ ...componentOverrides,
64
+ ...config.starlightCompat.components,
65
+ },
66
+ head: [
67
+ ...config.head,
68
+ {
69
+ tag: 'script',
70
+ content: `
71
+ function setupNavLinksInitial(){
72
+ let mode = localStorage.getItem("stl-nav-links-mode");
73
+ // initial guess
74
+ if(mode === null){
75
+ mode = window.innerWidth < 1000 ? "mobile" : "desktop";
76
+ }
77
+ document.documentElement.classList.add("stl-nav-links-mode-" + mode)
78
+ }
79
+ setupNavLinksInitial();
80
+ `,
81
+ },
82
+ // TODO: for users who are overriding the font stack in their own styles, how can we know that
83
+ // and preload their font instead of ours?
84
+ {
85
+ tag: 'link',
86
+ attrs: {
87
+ rel: 'preload',
88
+ as: 'font',
89
+ type: 'font/woff2',
90
+ crossorigin: 'anonymous',
91
+ href: geistPath,
92
+ },
93
+ },
94
+ ],
95
+ routeMiddleware: [...config.starlightCompat.routeMiddleware, '@stainless-api/docs/tabsMiddleware'],
96
+ customCss: ['@stainless-api/docs/theme', ...config.customCss],
97
+ plugins: [stainlessStarlight(config.apiReference), ...config.starlightCompat.plugins],
98
+ });
99
+ }
100
+
101
+ function stainlessDocsIntegration(config: NormalizedStainlessDocsConfig): AstroIntegration {
102
+ const virtualId = `virtual:stl-stl-starlight-virtual-module`;
103
+ // The '\0' prefix tells Vite “this is a virtual module” and prevents it from being resolved again.
104
+ const resolvedId = `\0${virtualId}`;
105
+ let redirects: NormalizedRedirectConfig | null = null;
106
+
107
+ return {
108
+ name: 'stl-docs-integration',
109
+ hooks: {
110
+ 'astro:config:setup': ({ updateConfig, command, config: astroConfig }) => {
111
+ // // we only handle redirects for builds
112
+ // // in dev, Astro handles them for us
113
+ if (command === 'build' && astroConfig.redirects) {
114
+ redirects = normalizeRedirects(astroConfig.redirects);
115
+ }
116
+
117
+ updateConfig({
118
+ vite: {
119
+ plugins: [
120
+ {
121
+ name: 'stl-docs-vite',
122
+ resolveId(id) {
123
+ if (id === virtualId) {
124
+ return resolvedId;
125
+ }
126
+ },
127
+ load(id) {
128
+ if (id === resolvedId) {
129
+ return buildVirtualModuleString({
130
+ TABS: config.tabs,
131
+ SPLIT_TABS_ENABLED: config.splitTabsEnabled,
132
+ HEADER_LINKS: config.header.links,
133
+ HEADER_LAYOUT: config.header.layout,
134
+ ENABLE_CLIENT_ROUTER: config.enableClientRouter,
135
+ });
136
+ }
137
+ },
138
+ },
139
+ ],
140
+ },
141
+ build: {
142
+ ...astroConfig.build,
143
+ redirects: false,
144
+ },
145
+ });
146
+ },
147
+ 'astro:build:done': ({ dir }) => {
148
+ if (redirects !== null) {
149
+ const stainlessDir = join(dir.pathname, '_stainless');
150
+ mkdirSync(stainlessDir);
151
+ const outputPath = join(stainlessDir, 'redirects.json');
152
+ writeFileSync(outputPath, JSON.stringify(redirects, null, 2), {
153
+ encoding: 'utf-8',
154
+ });
155
+ }
156
+ },
157
+ },
158
+ };
159
+ }
160
+
161
+ export function stainlessDocs(config: StainlessDocsUserConfig) {
162
+ const normalizedConfigResult = parseStlDocsConfig(config);
163
+ if (normalizedConfigResult.result === 'error') {
164
+ // TODO: would be good to use the astro logger somehow
165
+ console.error(normalizedConfigResult.message);
166
+ process.exit(1);
167
+ }
168
+ const normalizedConfig = normalizedConfigResult.config;
169
+ return [
170
+ react(),
171
+ stainlessDocsStarlightIntegration(normalizedConfig),
172
+ stainlessDocsIntegration(normalizedConfig),
173
+ ];
174
+ }
@@ -0,0 +1,160 @@
1
+ import type { StainlessStarlightUserConfig } from '../plugin/loadPluginConfig';
2
+ import type { StarlightUserConfig } from '@astrojs/starlight/types';
3
+ import type { ButtonVariant } from '@stainless-api/ui-primitives';
4
+ import type { AnchorHTMLAttributes } from 'react';
5
+ import type starlight from '@astrojs/starlight';
6
+
7
+ type StarlightConfig = Parameters<typeof starlight>[0];
8
+
9
+ type SidebarEntry = Exclude<StarlightConfig['sidebar'], undefined>[number];
10
+
11
+ export type Defined<T> = T extends undefined ? never : T;
12
+
13
+ export type StarlightConfigDefined = Defined<StarlightUserConfig>;
14
+
15
+ export type StarlightSidebarConfig = StarlightConfig['sidebar'];
16
+
17
+ type PassThroughStarlightConfigOptions = Pick<
18
+ StarlightConfigDefined,
19
+ | 'title'
20
+ | 'logo'
21
+ | 'description'
22
+ | 'tagline'
23
+ | 'head'
24
+ | 'defaultLocale'
25
+ | 'customCss'
26
+ | 'tableOfContents'
27
+ | 'titleDelimiter'
28
+ | 'disable404Route'
29
+ | 'editLink'
30
+ | 'favicon'
31
+ | 'locales'
32
+ | 'lastUpdated'
33
+ | 'pagination'
34
+ | 'sidebar'
35
+ >;
36
+
37
+ type ExperimentalStarlightCompatibilityConfig = Pick<
38
+ StarlightConfigDefined,
39
+ 'components' | 'routeMiddleware' | 'plugins'
40
+ >;
41
+
42
+ type Tabs = {
43
+ label: string;
44
+ link: string;
45
+ sidebar?: SidebarEntry[];
46
+ /**
47
+ * Whether to hide the tab in the tab bar.
48
+ * Defaults to `false`.
49
+ */
50
+ hidden?: boolean;
51
+ }[];
52
+
53
+ export type HeaderLink = {
54
+ label: string;
55
+ link: string;
56
+ variant?: ButtonVariant;
57
+ attrs?: AnchorHTMLAttributes<HTMLAnchorElement>;
58
+ };
59
+
60
+ export type StainlessDocsUserConfig = {
61
+ apiReference: StainlessStarlightUserConfig;
62
+ tabs?: Tabs;
63
+ header?: {
64
+ layout?: HeaderLayout;
65
+ links?: HeaderLink[];
66
+ };
67
+ experimental?: {
68
+ starlightCompat?: ExperimentalStarlightCompatibilityConfig;
69
+ enableClientRouter?: boolean;
70
+ };
71
+ } & PassThroughStarlightConfigOptions;
72
+
73
+ type HeaderLayout = 'default' | 'stacked';
74
+
75
+ function areSplitTabsEnabled(config: StainlessDocsUserConfig) {
76
+ if (config.sidebar) {
77
+ return false;
78
+ }
79
+ if (config.tabs?.length === 0) {
80
+ return false;
81
+ }
82
+
83
+ const hasTabsWithSidebar = config.tabs?.some((tab) => tab.sidebar !== undefined);
84
+ if (hasTabsWithSidebar) {
85
+ return true;
86
+ }
87
+
88
+ return false;
89
+ }
90
+
91
+ function normalizeRouteMiddleware(userConfig: StainlessDocsUserConfig) {
92
+ const entry = userConfig.experimental?.starlightCompat?.routeMiddleware;
93
+ if (entry === undefined) {
94
+ return [];
95
+ }
96
+ if (typeof entry === 'string') {
97
+ return [entry];
98
+ }
99
+ return entry;
100
+ }
101
+
102
+ function normalizeConfig(userConfig: StainlessDocsUserConfig) {
103
+ const splitTabsEnabled = areSplitTabsEnabled(userConfig);
104
+
105
+ const configWithDefaults = {
106
+ splitTabsEnabled,
107
+ tabs: userConfig.tabs ?? [],
108
+ head: userConfig.head ?? [],
109
+ defaultLocale: userConfig.defaultLocale,
110
+ customCss: userConfig.customCss ?? [],
111
+ header: {
112
+ layout: userConfig.header?.layout ?? 'default',
113
+ links: userConfig.header?.links ?? [],
114
+ },
115
+ starlightPassThrough: {
116
+ tableOfContents: userConfig.tableOfContents,
117
+ titleDelimiter: userConfig.titleDelimiter,
118
+ title: userConfig.title,
119
+ description: userConfig.description,
120
+ tagline: userConfig.tagline,
121
+ logo: userConfig.logo,
122
+ },
123
+ starlightCompat: {
124
+ components: userConfig.experimental?.starlightCompat?.components ?? {},
125
+ plugins: userConfig.experimental?.starlightCompat?.plugins ?? [],
126
+ routeMiddleware: normalizeRouteMiddleware(userConfig),
127
+ },
128
+ enableClientRouter: userConfig.experimental?.enableClientRouter ?? false,
129
+ apiReference: userConfig.apiReference,
130
+ sidebar: userConfig.sidebar,
131
+ };
132
+
133
+ return configWithDefaults;
134
+ }
135
+
136
+ export type NormalizedStainlessDocsConfig = ReturnType<typeof normalizeConfig>;
137
+
138
+ /*
139
+ The goal of the code in this file is to take a user's config and normalize it.
140
+ Specifically: we want a single complete config format used throughout the internals of the plugin.
141
+
142
+ We've tried to avoid any config values being optional/undefined. To accomplish this:
143
+ - Any optional config values should have their defaults set here: eg. basePath defaults to /api
144
+ - If a field is only used in certain contexts, we make each context a discriminated union (see SpecRetrieverConfig)
145
+ - We prefer empty arrays over undefined/null
146
+ */
147
+ export function parseStlDocsConfig(userConfig: StainlessDocsUserConfig) {
148
+ try {
149
+ const config = normalizeConfig(userConfig);
150
+ return {
151
+ result: 'success' as const,
152
+ config,
153
+ };
154
+ } catch (error) {
155
+ return {
156
+ result: 'error' as const,
157
+ message: error instanceof Error ? error.message : 'An unknown error occurred',
158
+ };
159
+ }
160
+ }
@@ -0,0 +1,33 @@
1
+ import type { RedirectConfig } from 'astro';
2
+
3
+ // astro redirect keys can be strings or objects, if they're strings, 301 is assumed
4
+ // this type/function normalizes them to the complete object form
5
+ export type NormalizedRedirectConfig = Record<string, { destination: string; status: number }>;
6
+
7
+ export function normalizeRedirects(redirects: Record<string, RedirectConfig> | null) {
8
+ if (!redirects) {
9
+ return null;
10
+ }
11
+
12
+ if (Object.keys(redirects).length === 0) {
13
+ return null;
14
+ }
15
+
16
+ const output: NormalizedRedirectConfig = {};
17
+ for (const [source, redirect] of Object.entries(redirects)) {
18
+ let destination: string;
19
+ let status: number;
20
+ if (typeof redirect === 'string') {
21
+ destination = redirect;
22
+ status = 301; // astro default
23
+ } else {
24
+ destination = redirect.destination;
25
+ status = redirect.status;
26
+ }
27
+ output[source] = {
28
+ destination,
29
+ status,
30
+ };
31
+ }
32
+ return output;
33
+ }
@@ -0,0 +1,183 @@
1
+ import type { StarlightRouteData } from '@astrojs/starlight/route-data';
2
+ import { defineRouteMiddleware } from '@astrojs/starlight/route-data';
3
+
4
+ import { SPLIT_TABS_ENABLED, TABS } from 'virtual:stl-stl-starlight-virtual-module';
5
+ import clsx from 'clsx';
6
+
7
+ // this fn is loaded in the plugin via addRouteMiddleware
8
+
9
+ type SidebarEntry = StarlightRouteData['sidebar'][number];
10
+
11
+ function recursiveGetLinks(entry: SidebarEntry[]) {
12
+ const links = new Set<string>();
13
+
14
+ for (const e of entry) {
15
+ if (e.type === 'link') {
16
+ let href = e.href;
17
+
18
+ if (href.endsWith('/') && href !== '/') {
19
+ href = href.slice(0, -1);
20
+ }
21
+
22
+ links.add(href);
23
+ }
24
+
25
+ if (e.type === 'group') {
26
+ recursiveGetLinks(e.entries).forEach((link) => links.add(link));
27
+ }
28
+ }
29
+ return links;
30
+ }
31
+
32
+ function getLinksByTab(sidebarEntries: SidebarEntry[]) {
33
+ const linksByTab = new Map<string, string>();
34
+
35
+ for (const entry of sidebarEntries) {
36
+ if (entry.type === 'group') {
37
+ const linksForTab = recursiveGetLinks(entry.entries);
38
+ for (const link of linksForTab) {
39
+ linksByTab.set(link, entry.label);
40
+ }
41
+ }
42
+ }
43
+
44
+ // we have to manually add the hrefs from the tab links to the linksByTab map
45
+ TABS.forEach((tab, index) => {
46
+ const indexStr = String(index);
47
+ linksByTab.set(tab.link, indexStr);
48
+ });
49
+
50
+ return linksByTab;
51
+ }
52
+
53
+ function getTabIndexForSlug(
54
+ linksByTab: Map<string, string>,
55
+ slug: string,
56
+ ): {
57
+ index: number;
58
+ match: 'exact' | 'prefix';
59
+ } | null {
60
+ // ↓ exact match eg. slug = "/blog" and there is a link containing "/blog"
61
+ let tab = linksByTab.get(slug);
62
+ if (typeof tab === 'string') {
63
+ return {
64
+ match: 'exact',
65
+ index: Number(tab),
66
+ };
67
+ }
68
+
69
+ // ↓ matching prefix, eg. slug = "/blog/posts/intro" and there is a link containing "/blog"
70
+
71
+ // All links in order of longest to shortest
72
+ // This way our matches will be the most specific possible
73
+ const linksArr = Array.from(linksByTab.keys()).sort((a, b) => b.length - a.length);
74
+
75
+ for (const link of linksArr) {
76
+ if (slug.startsWith(link)) {
77
+ const tab = linksByTab.get(link)!; // force unwrap bc we know its in the map
78
+ return {
79
+ match: 'prefix',
80
+ index: Number(tab),
81
+ };
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function getNonSplitLinksByTab() {
88
+ const linksByTab = new Map<string, string>();
89
+
90
+ for (let i = 0; i < TABS.length; i++) {
91
+ const tab = TABS[i];
92
+ linksByTab.set(tab.link, String(i));
93
+ }
94
+
95
+ return linksByTab;
96
+ }
97
+
98
+ export const onRequest = defineRouteMiddleware(async (context) => {
99
+ // if using content collection schema, use: context.locals.starlightRoute.entry.data.stainlessStarlight
100
+ // this worked without collections but relied on hijacking starlightRoute: context.props.frontmatter.stainlessStarlight
101
+
102
+ const slug = `/${context.locals.starlightRoute.id}`; // same as .slug but not deprecated
103
+
104
+ /*
105
+ In the index of our starlight plugin, we transform our "tabs" into a plain old Starlight sidebar.
106
+ Eg: tabs = [
107
+ {
108
+ label: "API Reference",
109
+ link: "/api",
110
+ items: [],
111
+ },
112
+ {
113
+ label: "Guides",
114
+ link: "/guides",
115
+ items: [],
116
+ },
117
+ ];
118
+ becomes a Starlight sidebar with the items for each tab in groups "0" and "1" (corresponding to the idx of the tab)
119
+ we do this bc Starlight has many advanced features (eg. auto-generated entries by directory) that we want to reuse
120
+
121
+ Once we get to middleware, our basic job is:
122
+ - find the appropriate tab given the current slug
123
+ - replace the sidebar with just the items for that tab
124
+ */
125
+
126
+ const linksByTab = SPLIT_TABS_ENABLED
127
+ ? getLinksByTab(context.locals.starlightRoute.sidebar)
128
+ : getNonSplitLinksByTab();
129
+
130
+ const activeTabIndex = getTabIndexForSlug(linksByTab, slug);
131
+
132
+ if (activeTabIndex === null) {
133
+ return;
134
+ }
135
+
136
+ // handling a very rare case where a page has no sidebar and we match a prefix
137
+ // a good example case is a splash page at a route like "/splash"
138
+ // if "/splash" isn't a nav link or an entry in a sidebar BUT there is a nav link for "/", it would be a prefix match
139
+ // in that case, we'd be highlighting the nav item for "/" which isn't the expected behavior
140
+ // so we just return early and there is no active tab index
141
+ if (activeTabIndex.match === 'prefix' && !context.locals.starlightRoute.hasSidebar) {
142
+ return;
143
+ }
144
+
145
+ // We store the active tab index so we can use it in our nav tabs component
146
+ context.locals.starlightRoute._stlStarlight = {
147
+ activeTabIndex: activeTabIndex.index,
148
+ };
149
+
150
+ // if split tabs are not enabled, we don't need to do anything else
151
+ // we can leave the sidebar as-is
152
+ if (!SPLIT_TABS_ENABLED) {
153
+ return;
154
+ }
155
+
156
+ const matchingGroup = context.locals.starlightRoute.sidebar.find((group) => {
157
+ return group.label === String(activeTabIndex.index);
158
+ });
159
+
160
+ if (!matchingGroup || matchingGroup.type !== 'group') {
161
+ return;
162
+ }
163
+
164
+ const hasEntries = matchingGroup.entries && matchingGroup.entries.length > 0;
165
+
166
+ const mobileLinks: SidebarEntry[] = TABS.map((tab, index) => ({
167
+ type: 'link' as const,
168
+ label: tab.label,
169
+ href: tab.link,
170
+ badge: undefined,
171
+ isCurrent: tab.link === slug,
172
+ attrs: {
173
+ class: clsx(
174
+ 'stl-mobile-only-sidebar-item',
175
+ hasEntries && index === TABS.length - 1 && 'stl-mobile-only-sidebar-item-last',
176
+ ),
177
+ },
178
+ }));
179
+
180
+ matchingGroup?.entries.unshift(...mobileLinks);
181
+
182
+ context.locals.starlightRoute.sidebar = matchingGroup.entries;
183
+ });