@stainless-api/docs 0.1.0-beta.132 → 0.1.0-beta.134
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.
- package/CHANGELOG.md +12 -0
- package/eslint-suppressions.json +0 -17
- package/package.json +17 -8
- package/plugin/components/SnippetCode.tsx +4 -35
- package/plugin/globalJs/copy.ts +1 -82
- package/plugin/index.ts +1 -116
- package/plugin/loadPluginConfig.ts +0 -7
- package/plugin/specs/generateSpec.ts +4 -5
- package/stl-docs/index.ts +6 -1
- package/stl-docs/loadStlDocsConfig.ts +7 -5
- package/stl-docs/og-image/components/OpenGraphFunctionSignature.tsx +64 -0
- package/stl-docs/og-image/components/OpenGraphImage.tsx +126 -0
- package/stl-docs/og-image/config.ts +56 -0
- package/stl-docs/og-image/image-gen/generate-api-reference-og-image.tsx +188 -0
- package/stl-docs/og-image/image-gen/generate-og-image.tsx +119 -0
- package/stl-docs/og-image/image-gen/get-logo-url.ts +47 -0
- package/stl-docs/og-image/index.ts +135 -0
- package/stl-docs/og-image/routes/add-og-image.ts +45 -0
- package/stl-docs/og-image/routes/get-api-reference-og-image.ts +36 -0
- package/stl-docs/og-image/routes/get-og-image.ts +28 -0
- package/stl-docs/og-image/theme.ts +43 -0
- package/stl-docs/og-image/utils.ts +14 -0
- package/stl-docs/schema-extension.ts +12 -0
- package/virtual-module.d.ts +17 -1
- package/playground-virtual-modules.d.ts +0 -96
- package/plugin/globalJs/create-playground.shim.ts +0 -3
- package/plugin/globalJs/playground-data.shim.ts +0 -1
- package/plugin/globalJs/playground-data.ts +0 -14
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { darkThemeVars, lightThemeVars, typography } from '../theme';
|
|
2
|
+
import { resolveLocalImageFile } from '../image-gen/get-logo-url';
|
|
3
|
+
import { OG_IMAGE_OPTIONS } from 'virtual:stainless-docs/docs-og-image';
|
|
4
|
+
|
|
5
|
+
/* The default open graph image template. It is expected to be used with @takumi-rs/image-response */
|
|
6
|
+
export default function OpenGraphImage({
|
|
7
|
+
title,
|
|
8
|
+
description,
|
|
9
|
+
logo,
|
|
10
|
+
children,
|
|
11
|
+
theme,
|
|
12
|
+
breadcrumbs,
|
|
13
|
+
}: {
|
|
14
|
+
title: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
logo?: string;
|
|
17
|
+
children?: React.ReactNode;
|
|
18
|
+
theme?: 'light' | 'dark';
|
|
19
|
+
breadcrumbs?: string[];
|
|
20
|
+
}) {
|
|
21
|
+
const colors = theme === 'dark' ? darkThemeVars : lightThemeVars;
|
|
22
|
+
|
|
23
|
+
const testLogo = OG_IMAGE_OPTIONS?.backgroundImage
|
|
24
|
+
? resolveLocalImageFile(OG_IMAGE_OPTIONS.backgroundImage.src)
|
|
25
|
+
: undefined;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
style={{
|
|
30
|
+
backgroundColor: colors.background,
|
|
31
|
+
width: '100%',
|
|
32
|
+
height: '100%',
|
|
33
|
+
display: 'flex',
|
|
34
|
+
alignItems: 'flex-start',
|
|
35
|
+
justifyContent: 'space-between',
|
|
36
|
+
flexDirection: 'column',
|
|
37
|
+
padding: '72px',
|
|
38
|
+
position: 'relative',
|
|
39
|
+
fontFeatureSettings: "'ss01' on, 'ss03' on, 'ss04' on, 'ss06' on",
|
|
40
|
+
lineHeight: `${typography.baseLineHeight}`,
|
|
41
|
+
fontSize: `${typography.baseFontSize}`,
|
|
42
|
+
letterSpacing: `${typography.baseLetterSpacing}`,
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
{testLogo && (
|
|
46
|
+
<img
|
|
47
|
+
src={testLogo}
|
|
48
|
+
alt="Background"
|
|
49
|
+
style={{
|
|
50
|
+
position: 'absolute',
|
|
51
|
+
top: 0,
|
|
52
|
+
right: 0,
|
|
53
|
+
zIndex: -1,
|
|
54
|
+
...OG_IMAGE_OPTIONS?.backgroundImage?.style,
|
|
55
|
+
}}
|
|
56
|
+
/>
|
|
57
|
+
)}
|
|
58
|
+
{logo && <img src={logo} alt="Logo" style={{ height: '80px', marginBottom: '24px' }} />}
|
|
59
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
|
|
60
|
+
{breadcrumbs && breadcrumbs.length > 0 && (
|
|
61
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: colors.foregroundMuted }}>
|
|
62
|
+
{breadcrumbs.map((crumb, index) => (
|
|
63
|
+
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
64
|
+
{index > 0 && (
|
|
65
|
+
<svg
|
|
66
|
+
width="32"
|
|
67
|
+
height="32"
|
|
68
|
+
viewBox="0 0 32 32"
|
|
69
|
+
fill="none"
|
|
70
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
71
|
+
>
|
|
72
|
+
<g opacity="0.6">
|
|
73
|
+
<path
|
|
74
|
+
d="M11.0573 7.05727C11.578 6.53657 12.422 6.53657 12.9427 7.05727L20.9427 15.0573C21.4634 15.578 21.4634 16.422 20.9427 16.9427L12.9427 24.9427C12.422 25.4634 11.578 25.4634 11.0573 24.9427C10.5366 24.422 10.5366 23.578 11.0573 23.0573L18.1146 16L11.0573 8.94269C10.5366 8.42199 10.5366 7.57797 11.0573 7.05727Z"
|
|
75
|
+
fill={colors.foreground}
|
|
76
|
+
/>
|
|
77
|
+
</g>
|
|
78
|
+
</svg>
|
|
79
|
+
)}
|
|
80
|
+
<span
|
|
81
|
+
style={{
|
|
82
|
+
fontSize: `${typography.breadcrumbFontSize}`,
|
|
83
|
+
lineHeight: `${typography.breadcrumbLineHeight}`,
|
|
84
|
+
letterSpacing: `${typography.breadcrumbLetterSpacing}`,
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{crumb}
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
<h1
|
|
94
|
+
style={{
|
|
95
|
+
marginBottom: '0',
|
|
96
|
+
marginTop: '0',
|
|
97
|
+
fontWeight: 600,
|
|
98
|
+
color: colors.foreground,
|
|
99
|
+
letterSpacing: `${typography.headerLetterSpacing}`,
|
|
100
|
+
lineClamp: 2,
|
|
101
|
+
textOverflow: 'ellipsis',
|
|
102
|
+
lineHeight: `${typography.headerLineHeight}`,
|
|
103
|
+
fontSize: `${typography.headerFontSize}`,
|
|
104
|
+
textWrap: 'balance',
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{title}
|
|
108
|
+
</h1>
|
|
109
|
+
{description && (
|
|
110
|
+
<p
|
|
111
|
+
style={{
|
|
112
|
+
color: colors.foregroundMuted,
|
|
113
|
+
marginBottom: 0,
|
|
114
|
+
marginTop: 0,
|
|
115
|
+
lineClamp: 2,
|
|
116
|
+
textOverflow: 'ellipsis',
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
{description}
|
|
120
|
+
</p>
|
|
121
|
+
)}
|
|
122
|
+
{children}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Exported utilites for @stainless-api/docs consumers
|
|
2
|
+
|
|
3
|
+
import type { ImageResponseOptions } from '@takumi-rs/image-response';
|
|
4
|
+
import type { CSSProperties } from 'react';
|
|
5
|
+
|
|
6
|
+
export type OGImageConfig = {
|
|
7
|
+
/**
|
|
8
|
+
* Path to source file for logo to include in generated OG images
|
|
9
|
+
*
|
|
10
|
+
* example: './src/assets/og-logo.png'
|
|
11
|
+
*/
|
|
12
|
+
logo?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Takumi ImageResponseOptions for OG image generation
|
|
15
|
+
*/
|
|
16
|
+
renderOptions?: Omit<ImageResponseOptions, 'fonts'>;
|
|
17
|
+
/**
|
|
18
|
+
* A background image for the OG images. A tailwind `tw` string can be provided to style the image.
|
|
19
|
+
*
|
|
20
|
+
* example: './src/assets/og-background-logo.png'
|
|
21
|
+
*/
|
|
22
|
+
backgroundImage?: {
|
|
23
|
+
/**
|
|
24
|
+
* Path to source file for background image
|
|
25
|
+
*
|
|
26
|
+
* example: './src/assets/og-background-logo.png'
|
|
27
|
+
*/
|
|
28
|
+
src: string;
|
|
29
|
+
/**
|
|
30
|
+
* Style applied to the background image using React CSSProperties
|
|
31
|
+
*
|
|
32
|
+
* example: { right: -20px }
|
|
33
|
+
*/
|
|
34
|
+
style?: CSSProperties;
|
|
35
|
+
};
|
|
36
|
+
/** Preferred theme for the OG images
|
|
37
|
+
*/
|
|
38
|
+
theme?: 'light' | 'dark';
|
|
39
|
+
/** The base path for the docs site. To be used when setting `base` within your astro config is not sufficient depending on hosting strategy.
|
|
40
|
+
* If your docs site is hosted at a subpath (e.g. example.com/docs), set the basePath to '/docs'.
|
|
41
|
+
*
|
|
42
|
+
* example: '/docs'
|
|
43
|
+
*/
|
|
44
|
+
basePath?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Override the default OG image components with custom implementations.
|
|
47
|
+
* Each value should be a file path to a component that exports a default React component.
|
|
48
|
+
*
|
|
49
|
+
* You can import the default components from `@stainless-api/docs/og-image/components/OpenGraphImage`
|
|
50
|
+
* and `@stainless-api/docs/og-image/components/OpenGraphFunctionSignature` to compose with them.
|
|
51
|
+
*/
|
|
52
|
+
components?: {
|
|
53
|
+
OpenGraphImage?: string;
|
|
54
|
+
OpenGraphFunctionSignature?: string;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { ImageResponse } from '@takumi-rs/image-response';
|
|
2
|
+
import { generateDocsRoutes } from '@stainless-api/docs/generate-docs-routes';
|
|
3
|
+
import { DocsLanguage, parseStainlessPath } from '@stainless-api/docs-ui/routing';
|
|
4
|
+
import { getResourceFromSpec } from '@stainless-api/docs-ui/utils';
|
|
5
|
+
import { ArrowDownLeft, ArrowUpRight, XIcon } from 'lucide-react';
|
|
6
|
+
import OpenGraphImage from 'virtual:stainless-docs/docs-og-image/components/OpenGraphImage';
|
|
7
|
+
import OpenGraphFunctionSignature from 'virtual:stainless-docs/docs-og-image/components/OpenGraphFunctionSignature';
|
|
8
|
+
import { LanguageDeclNodes, Method } from '@stainless/sdk-json';
|
|
9
|
+
import getLogoDataUrl from './get-logo-url';
|
|
10
|
+
import { notFoundResponse, renderOptions } from '../utils';
|
|
11
|
+
import { OG_IMAGE_OPTIONS } from 'virtual:stainless-docs/docs-og-image';
|
|
12
|
+
import { darkThemeVars, lightThemeVars } from '../theme';
|
|
13
|
+
import { generateApiBreadcrumbs } from '@stainless-api/docs-ui/components';
|
|
14
|
+
import { getSDKJSONInSSR } from '@stainless-api/docs/specs/fetchSpecSSR';
|
|
15
|
+
import { RESOLVED_API_REFERENCE_PATH } from 'virtual:stl-starlight-virtual-module';
|
|
16
|
+
import type * as SDKJSON from '@stainless/sdk-json';
|
|
17
|
+
|
|
18
|
+
type ApiReferenceRoute = ReturnType<typeof generateDocsRoutes>[number];
|
|
19
|
+
|
|
20
|
+
export default async function generateApiReferenceOgImage({
|
|
21
|
+
apiReferenceRoute,
|
|
22
|
+
slug,
|
|
23
|
+
}: {
|
|
24
|
+
apiReferenceRoute?: ApiReferenceRoute;
|
|
25
|
+
slug: string;
|
|
26
|
+
}) {
|
|
27
|
+
if (!apiReferenceRoute?.props.stainlessPath) return notFoundResponse();
|
|
28
|
+
|
|
29
|
+
const spec = await getSDKJSONInSSR(apiReferenceRoute.props.language);
|
|
30
|
+
|
|
31
|
+
const parsed = parseStainlessPath(apiReferenceRoute.props.stainlessPath);
|
|
32
|
+
const resource = getResourceFromSpec(apiReferenceRoute.props.stainlessPath, spec);
|
|
33
|
+
|
|
34
|
+
if (!resource || !parsed?.method || !resource.methods[parsed.method]) return notFoundResponse();
|
|
35
|
+
|
|
36
|
+
if (apiReferenceRoute.props.kind === 'http_method') {
|
|
37
|
+
const method = resource.methods[parsed.method]!;
|
|
38
|
+
return generateApiReferenceMethodOgImage({
|
|
39
|
+
method,
|
|
40
|
+
language: apiReferenceRoute.props.language,
|
|
41
|
+
stainlessPath: apiReferenceRoute.props.stainlessPath,
|
|
42
|
+
slug: `${RESOLVED_API_REFERENCE_PATH}/${slug}`,
|
|
43
|
+
spec,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const logoDataUrl = getLogoDataUrl();
|
|
48
|
+
|
|
49
|
+
return new ImageResponse(
|
|
50
|
+
<OpenGraphImage
|
|
51
|
+
title={resource.title}
|
|
52
|
+
description={`API Overview - ${apiReferenceRoute.props.language} `}
|
|
53
|
+
logo={logoDataUrl}
|
|
54
|
+
/>,
|
|
55
|
+
renderOptions,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function generateApiReferenceMethodOgImage({
|
|
60
|
+
method,
|
|
61
|
+
language,
|
|
62
|
+
stainlessPath,
|
|
63
|
+
slug,
|
|
64
|
+
spec,
|
|
65
|
+
}: {
|
|
66
|
+
method: Method;
|
|
67
|
+
language: DocsLanguage;
|
|
68
|
+
stainlessPath: string;
|
|
69
|
+
slug: string;
|
|
70
|
+
spec: SDKJSON.Spec;
|
|
71
|
+
}) {
|
|
72
|
+
const slugWithoutExtension = slug.replace(/\.[^/.]+$/, '');
|
|
73
|
+
const endpoint = method.endpoint.slice(method.endpoint.indexOf(' ') + 1);
|
|
74
|
+
const httpMethod = method.httpMethod.toUpperCase();
|
|
75
|
+
|
|
76
|
+
const decl = spec?.decls?.[language]?.[stainlessPath] as LanguageDeclNodes[keyof LanguageDeclNodes];
|
|
77
|
+
|
|
78
|
+
if (!decl) {
|
|
79
|
+
return notFoundResponse();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let params: { ident: string; optional?: boolean }[] | undefined = undefined;
|
|
83
|
+
let qualified: string | undefined = undefined;
|
|
84
|
+
|
|
85
|
+
if ('signature' in decl && decl.signature) {
|
|
86
|
+
params = decl.signature.parameters;
|
|
87
|
+
} else if ('parameters' in decl && decl.parameters) {
|
|
88
|
+
// @ts-expect-error TODO: this is breaking builds
|
|
89
|
+
params = decl.parameters;
|
|
90
|
+
} else if ('args' in decl && decl.args) {
|
|
91
|
+
params = decl.args;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if ('qualified' in decl && decl.qualified) {
|
|
95
|
+
qualified = decl.qualified;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const logoDataUrl = getLogoDataUrl();
|
|
99
|
+
const colors = OG_IMAGE_OPTIONS?.theme === 'dark' ? darkThemeVars : lightThemeVars;
|
|
100
|
+
const httpColors =
|
|
101
|
+
httpMethod === 'GET'
|
|
102
|
+
? { background: colors.greenBackground, text: colors.green }
|
|
103
|
+
: httpMethod === 'POST'
|
|
104
|
+
? { background: colors.blueBackground, text: colors.blue }
|
|
105
|
+
: httpMethod === 'PUT' || httpMethod === 'PATCH'
|
|
106
|
+
? { background: colors.orangeBackground, text: colors.orange }
|
|
107
|
+
: httpMethod === 'DELETE'
|
|
108
|
+
? { background: colors.redBackground, text: colors.red }
|
|
109
|
+
: { background: colors.foregroundMuted, text: colors.foreground };
|
|
110
|
+
// remove first and last breadcrumb (API Reference and current page)
|
|
111
|
+
const breadcrumbs = generateApiBreadcrumbs(
|
|
112
|
+
slugWithoutExtension,
|
|
113
|
+
spec,
|
|
114
|
+
RESOLVED_API_REFERENCE_PATH || '/api',
|
|
115
|
+
)?.slice(1, -1);
|
|
116
|
+
|
|
117
|
+
return new ImageResponse(
|
|
118
|
+
<OpenGraphImage
|
|
119
|
+
title={method.summary || method.title}
|
|
120
|
+
logo={logoDataUrl}
|
|
121
|
+
theme={OG_IMAGE_OPTIONS?.theme}
|
|
122
|
+
breadcrumbs={breadcrumbs ? breadcrumbs.map((b) => b.title) : undefined}
|
|
123
|
+
>
|
|
124
|
+
<div
|
|
125
|
+
style={{
|
|
126
|
+
display: 'flex',
|
|
127
|
+
flexDirection: 'column',
|
|
128
|
+
fontFamily: 'monospace',
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
<OpenGraphFunctionSignature
|
|
132
|
+
params={params}
|
|
133
|
+
fullyQualifiedName={qualified}
|
|
134
|
+
theme={OG_IMAGE_OPTIONS?.theme}
|
|
135
|
+
/>
|
|
136
|
+
<div
|
|
137
|
+
style={{
|
|
138
|
+
display: 'flex',
|
|
139
|
+
gap: '8px',
|
|
140
|
+
alignItems: 'center',
|
|
141
|
+
fontFamily: 'monospace',
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
<div
|
|
145
|
+
style={{
|
|
146
|
+
display: 'flex',
|
|
147
|
+
alignItems: 'center',
|
|
148
|
+
justifyContent: 'center',
|
|
149
|
+
paddingLeft: '2px',
|
|
150
|
+
paddingRight: '6px',
|
|
151
|
+
paddingTop: '4px',
|
|
152
|
+
paddingBottom: '4px',
|
|
153
|
+
borderRadius: '8px',
|
|
154
|
+
fontWeight: 600,
|
|
155
|
+
color: httpColors.text,
|
|
156
|
+
backgroundColor: httpColors.background,
|
|
157
|
+
lineHeight: '100%',
|
|
158
|
+
fontSize: '25px',
|
|
159
|
+
stroke: colors.foreground,
|
|
160
|
+
fontFamily: 'monospace',
|
|
161
|
+
flexShrink: 0,
|
|
162
|
+
}}
|
|
163
|
+
>
|
|
164
|
+
{httpMethod === 'GET' && <ArrowDownLeft size={36} color={colors.green} />}
|
|
165
|
+
{httpMethod === 'POST' && <ArrowUpRight size={36} color={colors.blue} />}
|
|
166
|
+
{(httpMethod === 'PUT' || httpMethod === 'PATCH') && (
|
|
167
|
+
<ArrowUpRight size={36} color={colors.orange} />
|
|
168
|
+
)}
|
|
169
|
+
{httpMethod === 'DELETE' && <XIcon size={36} color={colors.red} />}
|
|
170
|
+
{httpMethod}
|
|
171
|
+
</div>
|
|
172
|
+
<div
|
|
173
|
+
style={{
|
|
174
|
+
lineClamp: 1,
|
|
175
|
+
overflow: 'hidden',
|
|
176
|
+
textOverflow: 'ellipsis',
|
|
177
|
+
color: colors.foregroundMuted,
|
|
178
|
+
fontFamily: 'monospace',
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
{endpoint}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</OpenGraphImage>,
|
|
186
|
+
renderOptions,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { getCollection } from 'astro:content';
|
|
2
|
+
import { z } from 'astro/zod';
|
|
3
|
+
import { ImageResponse } from '@takumi-rs/image-response';
|
|
4
|
+
import { Tabs } from '@stainless-api/docs/docs-config';
|
|
5
|
+
import OpenGraphImage from 'virtual:stainless-docs/docs-og-image/components/OpenGraphImage';
|
|
6
|
+
import { OG_IMAGE_OPTIONS } from 'virtual:stainless-docs/docs-og-image';
|
|
7
|
+
import { TABS } from 'virtual:stl-docs-virtual-module';
|
|
8
|
+
import getLogoDataUrl from './get-logo-url';
|
|
9
|
+
import { stainlessDocsSchemaExtension } from '../../schema-extension';
|
|
10
|
+
import { notFoundResponse, renderOptions } from '../utils';
|
|
11
|
+
|
|
12
|
+
type GetcollectionReturnType = Awaited<ReturnType<typeof getCollection<'docs'>>>[number];
|
|
13
|
+
|
|
14
|
+
type PageEntry = {
|
|
15
|
+
data: GetcollectionReturnType['data'] & z.infer<typeof stainlessDocsSchemaExtension>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let contentEntriesCache: { id: string }[] | null = null;
|
|
19
|
+
|
|
20
|
+
async function getContentEntries(): Promise<{ id: string }[]> {
|
|
21
|
+
if (contentEntriesCache === null) {
|
|
22
|
+
contentEntriesCache = await getCollection('docs').then((entries) =>
|
|
23
|
+
entries.map((entry) => ({ id: entry.id })),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return contentEntriesCache ?? [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function resolveAutogenerateEntry(autogenerate: {
|
|
30
|
+
directory: string;
|
|
31
|
+
}): Promise<NonNullable<Tabs[0]['sidebar']>> {
|
|
32
|
+
const entries = await getContentEntries();
|
|
33
|
+
const directoryPrefix = autogenerate.directory.endsWith('/')
|
|
34
|
+
? autogenerate.directory
|
|
35
|
+
: `${autogenerate.directory}/`;
|
|
36
|
+
|
|
37
|
+
const matchingEntries = entries
|
|
38
|
+
.filter((entry) => {
|
|
39
|
+
return entry.id === autogenerate.directory || entry.id.startsWith(directoryPrefix);
|
|
40
|
+
})
|
|
41
|
+
.map((entry) => entry.id)
|
|
42
|
+
.sort();
|
|
43
|
+
|
|
44
|
+
return matchingEntries;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const findBreadcrumbsInSidebar = async (
|
|
48
|
+
data: NonNullable<Tabs[0]['sidebar']>,
|
|
49
|
+
slug: string,
|
|
50
|
+
): Promise<string[] | null> => {
|
|
51
|
+
for (const entry of data) {
|
|
52
|
+
if (typeof entry === 'string') {
|
|
53
|
+
if (entry === slug) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
if ('link' in entry && entry.link === slug) {
|
|
58
|
+
return [entry.label];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if ('autogenerate' in entry && entry.autogenerate) {
|
|
62
|
+
const resolvedItems = await resolveAutogenerateEntry(entry.autogenerate);
|
|
63
|
+
const breadcrumbs = await findBreadcrumbsInSidebar(resolvedItems, slug);
|
|
64
|
+
if (breadcrumbs) {
|
|
65
|
+
return [entry.label, ...breadcrumbs];
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const children = ('sidebar' in entry && entry.sidebar) || ('items' in entry && entry.items);
|
|
71
|
+
if (children) {
|
|
72
|
+
const breadcrumbs = await findBreadcrumbsInSidebar(children as NonNullable<Tabs[0]['sidebar']>, slug);
|
|
73
|
+
if (breadcrumbs) {
|
|
74
|
+
return [entry.label!, ...breadcrumbs];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const findBreadcrumbs = async (data: Tabs, slug: string): Promise<string[] | undefined> => {
|
|
83
|
+
for (const tab of data) {
|
|
84
|
+
if (tab.link === slug) {
|
|
85
|
+
return [tab.label];
|
|
86
|
+
}
|
|
87
|
+
if (!tab.sidebar) continue;
|
|
88
|
+
const breadcrumbs = await findBreadcrumbsInSidebar(tab.sidebar, slug);
|
|
89
|
+
if (breadcrumbs) {
|
|
90
|
+
return [tab.label, ...breadcrumbs.map((label) => label)];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
async function generateOgImage({ page, slug }: { page?: PageEntry; slug: string }) {
|
|
97
|
+
if (!page) {
|
|
98
|
+
return notFoundResponse();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const logoDataUrl = getLogoDataUrl({
|
|
102
|
+
logo: page.data.ogImageOptions?.logo,
|
|
103
|
+
theme: page.data.ogImageOptions?.theme || OG_IMAGE_OPTIONS?.theme,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const breadcrumbs = page.data.template !== 'splash' ? await findBreadcrumbs(TABS, slug) : undefined;
|
|
107
|
+
return new ImageResponse(
|
|
108
|
+
<OpenGraphImage
|
|
109
|
+
title={page.data.ogImageOptions?.title ?? page.data.title}
|
|
110
|
+
description={page.data.ogImageOptions?.description ?? page.data.description}
|
|
111
|
+
logo={logoDataUrl}
|
|
112
|
+
theme={page.data.ogImageOptions?.theme || OG_IMAGE_OPTIONS?.theme}
|
|
113
|
+
breadcrumbs={breadcrumbs}
|
|
114
|
+
/>,
|
|
115
|
+
renderOptions,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export default generateOgImage;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { LOGO, OG_IMAGE_OPTIONS } from 'virtual:stainless-docs/docs-og-image';
|
|
4
|
+
|
|
5
|
+
export function resolveLocalImageFile(logoPath: string): string | undefined {
|
|
6
|
+
try {
|
|
7
|
+
// Remove leading slash and resolve from project root
|
|
8
|
+
const filePath = join(process.cwd(), logoPath.replace(/^\//, ''));
|
|
9
|
+
const fileBuffer = readFileSync(filePath);
|
|
10
|
+
|
|
11
|
+
// Determine mime type from extension
|
|
12
|
+
const ext = logoPath.split('.').pop()?.toLowerCase();
|
|
13
|
+
|
|
14
|
+
const mimeType = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`;
|
|
15
|
+
|
|
16
|
+
return `data:${mimeType};base64,${fileBuffer.toString('base64')}`;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.warn('Failed to load logo for OG image:', error);
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Convert logo to base64 data URL if it exists
|
|
24
|
+
function getLogoDataUrl({ logo, theme }: { logo?: string; theme?: 'light' | 'dark' } = {}):
|
|
25
|
+
| string
|
|
26
|
+
| undefined {
|
|
27
|
+
const logoConfig = logo ?? OG_IMAGE_OPTIONS?.logo ?? LOGO;
|
|
28
|
+
if (!logoConfig) return undefined;
|
|
29
|
+
|
|
30
|
+
// Handle string path or object with src/light properties
|
|
31
|
+
let logoPath: string | undefined;
|
|
32
|
+
if (typeof logoConfig === 'string') {
|
|
33
|
+
logoPath = logoConfig;
|
|
34
|
+
} else if ('src' in logoConfig) {
|
|
35
|
+
logoPath = logoConfig.src;
|
|
36
|
+
} else if ('dark' in logoConfig && theme === 'dark') {
|
|
37
|
+
logoPath = logoConfig.dark;
|
|
38
|
+
} else if ('light' in logoConfig) {
|
|
39
|
+
logoPath = logoConfig.light;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!logoPath) return undefined;
|
|
43
|
+
|
|
44
|
+
return resolveLocalImageFile(logoPath);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default getLogoDataUrl;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { StarlightPlugin } from '@astrojs/starlight/types';
|
|
2
|
+
import type { NormalizedStainlessDocsConfig } from '../loadStlDocsConfig';
|
|
3
|
+
import { resolveSrcFile } from '../../resolveSrcFile';
|
|
4
|
+
import { resolve } from 'path';
|
|
5
|
+
import type { OGImageConfig } from './config';
|
|
6
|
+
import { buildVirtualModuleString } from '../../shared/virtualModule';
|
|
7
|
+
|
|
8
|
+
// The '\0' prefix tells Vite "this is a virtual module" and prevents it from being resolved again.
|
|
9
|
+
function resolveVirtualModuleId<T extends string>(id: T): `\0${T}` {
|
|
10
|
+
return `\0${id}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const OG_IMAGE_DIR = '/stl-docs/og-image';
|
|
14
|
+
|
|
15
|
+
const stainlessComponentDefaults = {
|
|
16
|
+
OpenGraphImage: resolveSrcFile(OG_IMAGE_DIR, 'components/OpenGraphImage.tsx'),
|
|
17
|
+
OpenGraphFunctionSignature: resolveSrcFile(OG_IMAGE_DIR, 'components/OpenGraphFunctionSignature.tsx'),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function checkTakumiInstalled(): boolean {
|
|
21
|
+
try {
|
|
22
|
+
import.meta.resolve('@takumi-rs/image-response');
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ogImageStarlightPlugin(
|
|
30
|
+
config: OGImageConfig | undefined,
|
|
31
|
+
stainlessDocsConfig: NormalizedStainlessDocsConfig,
|
|
32
|
+
): StarlightPlugin {
|
|
33
|
+
return {
|
|
34
|
+
name: 'stainless-og-image',
|
|
35
|
+
hooks: {
|
|
36
|
+
'config:setup': ({ astroConfig, addRouteMiddleware, addIntegration, logger, command }) => {
|
|
37
|
+
if (command !== 'build' && command !== 'dev') {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!checkTakumiInstalled()) {
|
|
42
|
+
logger.error(
|
|
43
|
+
'The "@takumi-rs/image-response" package is required to use OG image generation. ' +
|
|
44
|
+
'Please install it: npm install @takumi-rs/image-response',
|
|
45
|
+
);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!astroConfig.site) {
|
|
50
|
+
logger.warn('astro.config.site is not set. Open Graph images will not be generated.');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
addRouteMiddleware({
|
|
54
|
+
entrypoint: resolveSrcFile(OG_IMAGE_DIR, 'routes/add-og-image.ts'),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
addIntegration({
|
|
58
|
+
name: 'stainless-docs-og-image-astro-integration',
|
|
59
|
+
hooks: {
|
|
60
|
+
'astro:config:setup': ({ updateConfig, injectRoute, command, config: astroConfig }) => {
|
|
61
|
+
const resolvePath = (id: string) =>
|
|
62
|
+
JSON.stringify(id.startsWith('.') ? resolve(astroConfig.root.pathname, id) : id);
|
|
63
|
+
|
|
64
|
+
const userComponents = Object.fromEntries(
|
|
65
|
+
Object.entries(config?.components ?? {}).flatMap(([key, value]) =>
|
|
66
|
+
value !== undefined ? [[key, value]] : [],
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const allComponents: Record<string, string> = {
|
|
71
|
+
...stainlessComponentDefaults,
|
|
72
|
+
...userComponents,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const modules = Object.fromEntries(
|
|
76
|
+
Object.entries(allComponents).map(([name, path]) => [
|
|
77
|
+
`virtual:stainless-docs/docs-og-image/components/${name}`,
|
|
78
|
+
`export { default } from ${resolvePath(path)};`,
|
|
79
|
+
]),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const resolutionMap = Object.fromEntries(
|
|
83
|
+
Object.keys(modules).map((key) => [resolveVirtualModuleId(key), key]),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const virtualId = `virtual:stainless-docs/docs-og-image`;
|
|
87
|
+
|
|
88
|
+
updateConfig({
|
|
89
|
+
vite: {
|
|
90
|
+
plugins: [
|
|
91
|
+
{
|
|
92
|
+
name: '@stainless-api/docs-og-image-vite',
|
|
93
|
+
resolveId(id) {
|
|
94
|
+
if (id in modules || id == virtualId) {
|
|
95
|
+
return resolveVirtualModuleId(id);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
load(id) {
|
|
99
|
+
if (id === resolveVirtualModuleId(virtualId)) {
|
|
100
|
+
return buildVirtualModuleString({
|
|
101
|
+
LOGO: stainlessDocsConfig?.starlightPassThrough?.logo,
|
|
102
|
+
OG_IMAGE_OPTIONS: config,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const resolution = resolutionMap[id];
|
|
106
|
+
if (resolution) return modules[resolution];
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
injectRoute({
|
|
114
|
+
pattern: `/og/[...slug].png`,
|
|
115
|
+
entrypoint: resolveSrcFile(OG_IMAGE_DIR, 'routes/get-og-image.ts'),
|
|
116
|
+
prerender: command === 'build',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (stainlessDocsConfig.apiReference !== null) {
|
|
120
|
+
const apiBasePath = stainlessDocsConfig.apiReference?.basePath ?? '/api';
|
|
121
|
+
const normalizedBasePath = apiBasePath.replace(/^\/+|\/+$/g, '');
|
|
122
|
+
|
|
123
|
+
injectRoute({
|
|
124
|
+
pattern: `/og/${normalizedBasePath}/[...slug].png`,
|
|
125
|
+
entrypoint: resolveSrcFile(OG_IMAGE_DIR, 'routes/get-api-reference-og-image.ts'),
|
|
126
|
+
prerender: command === 'build',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|