mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-14 06:35:17 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0780396e81 | |||
| ce082549d8 | |||
| d9c8d57e8e | |||
| bae43d08ff | |||
| 0d615e3888 | |||
| 68287d3df4 | |||
| dff08ae3e3 | |||
| 8cfa67c1dd | |||
| b41d425993 | |||
| 12c7862250 | |||
| 6f54826296 | |||
| ccf2cffc4b | |||
| f7b801b4c8 | |||
| 95f2aa45eb | |||
| 9524ff5f5a | |||
| 1823101b03 | |||
| 1a8cfd2a8b | |||
| 5b4e710770 |
+1
-1
@@ -1 +1 @@
|
||||
1.1.40
|
||||
1.2.1
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Support llms.txt
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Track new events for site insights when ads are being clicked
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix default outline list styling
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Cache API spec for 24 hours, revalidated every 2 hours
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Do not dereference before caching OpenAPI spec.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Fix an issue where a response object using a special ref would crash the page.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Add feedback form to page rating control
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
New highlight colors
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Improve performances by loading Scalar API Client only when the button is clicked
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Fix internal properties appearing in OpenAPI docs.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@gitbook/react-openapi': major
|
||||
---
|
||||
|
||||
Use `@scalar/openapi-parser` to be more resilient and perf on OpenAPI spec parsing:
|
||||
|
||||
- `fetcher.fetch` must now returns a valid OpenAPI document
|
||||
- `parseOpenAPIV3` has been replaced by `parseOpenAPI`
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve OpenAPI rendering performances by caching markdown parsing
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve performances by highlighting code client-side if the code block is offscreen
|
||||
@@ -35,8 +35,10 @@ To run a local version of this project, please follow these simple steps.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (Version: >=18.x)
|
||||
- Bun
|
||||
- Node.js (Version: >=20.8)
|
||||
- Use nvm for easy Node management
|
||||
- Bun (Version: >=1.2.1)
|
||||
- We use a text-based lockfile which isn't supported below 1.2.1
|
||||
|
||||
### Set up
|
||||
|
||||
|
||||
+3
-2
@@ -4,7 +4,7 @@
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"prettier": "^3.0.3",
|
||||
"turbo": "^2.1.2"
|
||||
"turbo": "^2.3.4"
|
||||
},
|
||||
"packageManager": "bun@1.1.18",
|
||||
"overrides": {
|
||||
@@ -17,6 +17,7 @@
|
||||
"dev": "turbo run dev --ui=stream",
|
||||
"build": "turbo run build",
|
||||
"build:cloudflare": "turbo run build:cloudflare",
|
||||
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
|
||||
"lint": "turbo run lint",
|
||||
"lint:fix": "turbo run lint -- --fix",
|
||||
"typecheck": "turbo run typecheck",
|
||||
@@ -35,6 +36,6 @@
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch"
|
||||
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,9 @@ const testCases: TestsCase[] = [
|
||||
name: 'AI Search',
|
||||
url: '?q=What+is+GitBook%3F&ask=true',
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible();
|
||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
@@ -322,7 +324,9 @@ const testCases: TestsCase[] = [
|
||||
name: 'AI Search',
|
||||
url: '?q=What+is+GitBook%3F&ask=true',
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible();
|
||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.88.1",
|
||||
"@gitbook/api": "^0.89.0",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
@@ -40,8 +40,9 @@
|
||||
"jsontoxml": "^1.0.1",
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"memoizee": "^0.4.15",
|
||||
"next": "14.2.15",
|
||||
"next": "14.2.23",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
@@ -69,11 +70,13 @@
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^3.10.0",
|
||||
"@cloudflare/next-on-pages": "1.13.7",
|
||||
"vercel": "^39.3.0",
|
||||
"@cloudflare/workers-types": "^4.20241230.0",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsontoxml": "^1.0.5",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^20",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/parse-cache-control": "^1.0.4",
|
||||
|
||||
@@ -109,10 +109,7 @@ export default async function Page(props: {
|
||||
page={page}
|
||||
ancestors={ancestors}
|
||||
document={document}
|
||||
withPageFeedback={
|
||||
// Display the page feedback in the page footer if the aside is not visible
|
||||
withPageFeedback && !page.layout.outline
|
||||
}
|
||||
withPageFeedback={withPageFeedback}
|
||||
/>
|
||||
</div>
|
||||
<React.Suspense fallback={null}>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { SiteSection, SiteSpace, SiteStructure } from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import { Heading, ListItem, Paragraph, Root, RootContent } from 'mdast';
|
||||
import { toMarkdown } from 'mdast-util-to-markdown';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { getPublishedContentSite, getRevisionPages } from '@/lib/api';
|
||||
import { getAbsoluteHref } from '@/lib/links';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { joinPath } from '@/lib/paths';
|
||||
import { checkIsRootPointer, getSiteContentPointer } from '@/lib/pointer';
|
||||
import { getIndexablePages } from '@/lib/sitemap';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/**
|
||||
* Generate a llms.txt file for the site.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const pointer = await getSiteContentPointer();
|
||||
|
||||
const { structure: siteStructure, site } = await getPublishedContentSite({
|
||||
organizationId: pointer.organizationId,
|
||||
siteId: pointer.siteId,
|
||||
siteShareKey: pointer.siteShareKey,
|
||||
});
|
||||
|
||||
if (!checkIsRootPointer(pointer, siteStructure)) {
|
||||
return new Response('llms.txt is only served from the root of the site', { status: 404 });
|
||||
}
|
||||
|
||||
const tree: Root = {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'heading',
|
||||
depth: 1,
|
||||
children: [{ type: 'text', value: site.title }],
|
||||
},
|
||||
...(await getNodesFromSiteStructure(siteStructure)),
|
||||
],
|
||||
};
|
||||
|
||||
return new Response(
|
||||
toMarkdown(tree, {
|
||||
bullet: '-',
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MDAST nodes from site structure.
|
||||
*/
|
||||
async function getNodesFromSiteStructure(siteStructure: SiteStructure): Promise<RootContent[]> {
|
||||
switch (siteStructure.type) {
|
||||
case 'sections':
|
||||
return getNodesFromSections(siteStructure.structure);
|
||||
case 'siteSpaces':
|
||||
return getNodesFromSiteSpaces(siteStructure.structure, { heading: true });
|
||||
default:
|
||||
assertNever(siteStructure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MDAST nodes from site sections.
|
||||
*/
|
||||
async function getNodesFromSections(siteSections: SiteSection[]): Promise<RootContent[]> {
|
||||
const all = await Promise.all(
|
||||
siteSections.map(async (siteSection): Promise<RootContent[]> => {
|
||||
const siteSpaceNodes = await getNodesFromSiteSpaces(siteSection.siteSpaces, {
|
||||
heading: false,
|
||||
});
|
||||
return [
|
||||
{
|
||||
type: 'heading',
|
||||
depth: 2,
|
||||
children: [{ type: 'text', value: siteSection.title }],
|
||||
},
|
||||
...siteSpaceNodes,
|
||||
];
|
||||
}),
|
||||
);
|
||||
return all.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MDAST nodes from site spaces.
|
||||
*/
|
||||
async function getNodesFromSiteSpaces(
|
||||
siteSpaces: SiteSpace[],
|
||||
options: {
|
||||
/**
|
||||
* Includes a heading for each site space.
|
||||
*/
|
||||
heading?: boolean;
|
||||
},
|
||||
): Promise<RootContent[]> {
|
||||
const all = await Promise.all(
|
||||
siteSpaces.map(async (siteSpace): Promise<RootContent[]> => {
|
||||
const siteSpaceUrl = siteSpace.urls.published;
|
||||
if (!siteSpaceUrl) {
|
||||
return [];
|
||||
}
|
||||
const rootPages = await getRevisionPages(siteSpace.space.id, siteSpace.space.revision, {
|
||||
metadata: false,
|
||||
});
|
||||
const pages = getIndexablePages(rootPages);
|
||||
const listChildren = await Promise.all(
|
||||
pages.map(async ({ page }): Promise<ListItem> => {
|
||||
const url = await getAbsoluteHref(
|
||||
joinPath(new URL(siteSpaceUrl).pathname, getPagePath(rootPages, page)),
|
||||
true,
|
||||
);
|
||||
const children: Paragraph['children'] = [
|
||||
{
|
||||
type: 'link',
|
||||
url,
|
||||
children: [{ type: 'text', value: page.title }],
|
||||
},
|
||||
];
|
||||
if (page.description) {
|
||||
children.push({ type: 'text', value: `: ${page.description}` });
|
||||
}
|
||||
return {
|
||||
type: 'listItem',
|
||||
children: [{ type: 'paragraph', children }],
|
||||
};
|
||||
}),
|
||||
);
|
||||
const nodes: RootContent[] = [];
|
||||
if (options.heading) {
|
||||
nodes.push({
|
||||
type: 'heading',
|
||||
depth: 2,
|
||||
children: [{ type: 'text', value: siteSpace.title }],
|
||||
});
|
||||
}
|
||||
nodes.push({
|
||||
type: 'list',
|
||||
spread: false,
|
||||
children: listChildren,
|
||||
});
|
||||
return nodes;
|
||||
}),
|
||||
);
|
||||
return all.flat();
|
||||
}
|
||||
@@ -22,7 +22,6 @@ export async function GET() {
|
||||
siteShareKey: pointer.siteShareKey,
|
||||
});
|
||||
|
||||
// This sitemap is only available at root (/sitemap.xml).
|
||||
if (!checkIsRootPointer(pointer, siteStructure)) {
|
||||
return new Response('Root sitemap is only served from the root of the site', {
|
||||
status: 404,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
RevisionPageDocument,
|
||||
RevisionPageGroup,
|
||||
SiteCustomizationSettings,
|
||||
SiteInsightsTrademarkPlacement,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
@@ -146,7 +147,11 @@ export default async function PDFHTMLOutput(props: {
|
||||
limit={pdfParams.limit ?? DEFAULT_LIMIT}
|
||||
trademark={
|
||||
customization.trademark.enabled ? (
|
||||
<TrademarkLink space={space} customization={customization} />
|
||||
<TrademarkLink
|
||||
space={space}
|
||||
customization={customization}
|
||||
placement={SiteInsightsTrademarkPlacement.Footer}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Space } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { headers } from 'next/headers';
|
||||
import React from 'react';
|
||||
|
||||
import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api';
|
||||
@@ -43,8 +44,15 @@ function ToolbarLayout(props: { children: React.ReactNode }) {
|
||||
/**
|
||||
* Toolbar with information for the content admin when previewing a revision or change-request.
|
||||
*/
|
||||
export function AdminToolbar(props: AdminToolbarProps) {
|
||||
export async function AdminToolbar(props: AdminToolbarProps) {
|
||||
const { content } = props;
|
||||
const mode = await headers().get('x-gitbook-mode');
|
||||
|
||||
if (mode === 'multi-id') {
|
||||
// We don't show the admin toolbar in multi-id mode, as it's used for previewing in the dashboard.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (content.changeRequestId) {
|
||||
return (
|
||||
<ChangeRequestToolbar
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { SiteAds, SiteAdsStatus } from '@gitbook/api';
|
||||
import {
|
||||
SiteAds,
|
||||
SiteAdsStatus,
|
||||
SiteInsightsAdPlacement,
|
||||
SiteInsightsAd,
|
||||
SiteInsightsTrademarkPlacement,
|
||||
} from '@gitbook/api';
|
||||
import * as React from 'react';
|
||||
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { renderAd } from './renderAd';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Link } from '../primitives';
|
||||
|
||||
/**
|
||||
* Zone ID provided by BuySellAds for the preview.
|
||||
@@ -28,7 +36,7 @@ export function Ad({
|
||||
}: {
|
||||
zoneId: string | null;
|
||||
spaceId: string;
|
||||
placement: string;
|
||||
placement: SiteInsightsAdPlacement;
|
||||
ignore: boolean;
|
||||
style?: ClassValue;
|
||||
siteAdsStatus?: SiteAds['status'];
|
||||
@@ -36,7 +44,20 @@ export function Ad({
|
||||
}) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [ad, setAd] = React.useState<React.ReactNode | undefined>(undefined);
|
||||
const [ad, setAd] = React.useState<
|
||||
{ children: React.ReactNode; insightsAd: SiteInsightsAd | null } | undefined
|
||||
>(undefined);
|
||||
const trackEvent = useTrackEvent();
|
||||
|
||||
// Track display of the ad
|
||||
React.useEffect(() => {
|
||||
if (ad?.insightsAd) {
|
||||
trackEvent({
|
||||
type: 'ad_display',
|
||||
ad: ad.insightsAd,
|
||||
});
|
||||
}
|
||||
}, [ad]);
|
||||
|
||||
// Observe the container visibility
|
||||
React.useEffect(() => {
|
||||
@@ -121,7 +142,7 @@ export function Ad({
|
||||
<div ref={containerRef} className={tcls(style)} data-visual-test="removed">
|
||||
{ad ? (
|
||||
<>
|
||||
{ad}
|
||||
{ad.children}
|
||||
<AdSponsoredLink spaceId={spaceId} />
|
||||
</>
|
||||
) : null}
|
||||
@@ -132,6 +153,7 @@ export function Ad({
|
||||
function AdSponsoredLink(props: { spaceId: string }) {
|
||||
const { spaceId } = props;
|
||||
const language = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
|
||||
const viaUrl = new URL('https://www.gitbook.com');
|
||||
viaUrl.searchParams.set('utm_source', 'content');
|
||||
@@ -149,9 +171,17 @@ function AdSponsoredLink(props: { spaceId: string }) {
|
||||
'dark:text-light/5',
|
||||
)}
|
||||
>
|
||||
<a target="_blank" href={viaUrl.toString()} className={tcls('hover:underline')}>
|
||||
<Link
|
||||
target="_blank"
|
||||
href={viaUrl.toString()}
|
||||
className={tcls('hover:underline')}
|
||||
insights={{
|
||||
type: 'trademark_click',
|
||||
placement: SiteInsightsTrademarkPlacement.Ad,
|
||||
}}
|
||||
>
|
||||
{t(language, 'sponsored_via_gitbook')}
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
import { SiteInsightsAd } from '@gitbook/api';
|
||||
import * as React from 'react';
|
||||
|
||||
import { getResizedImageURL } from '@/lib/images';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AdItem } from './types';
|
||||
import { Link } from '../primitives';
|
||||
|
||||
/**
|
||||
* Classic rendering for an ad.
|
||||
*/
|
||||
export async function AdClassicRendering({ ad }: { ad: AdItem }) {
|
||||
export async function AdClassicRendering({
|
||||
ad,
|
||||
insightsAd,
|
||||
}: {
|
||||
ad: AdItem;
|
||||
insightsAd: SiteInsightsAd | null;
|
||||
}) {
|
||||
const smallImgSrc =
|
||||
'smallImage' in ad ? await getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 }) : null;
|
||||
const logoSrc =
|
||||
'logo' in ad ? await getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 }) : null;
|
||||
return (
|
||||
<a
|
||||
<Link
|
||||
rel="sponsored noopener"
|
||||
target="_blank"
|
||||
insights={
|
||||
insightsAd
|
||||
? {
|
||||
type: 'ad_click',
|
||||
ad: insightsAd,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
@@ -29,8 +47,6 @@ export async function AdClassicRendering({ ad }: { ad: AdItem }) {
|
||||
'p-4',
|
||||
)}
|
||||
href={ad.statlink}
|
||||
rel="sponsored noopener"
|
||||
target="_blank"
|
||||
>
|
||||
{smallImgSrc && 'smallImage' in ad ? (
|
||||
<div>
|
||||
@@ -47,6 +63,6 @@ export async function AdClassicRendering({ ad }: { ad: AdItem }) {
|
||||
<div className={tcls('flex', 'flex-col')}>
|
||||
<div className={tcls('text-xs')}>{ad.description}</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SiteInsightsAd } from '@gitbook/api';
|
||||
import * as React from 'react';
|
||||
|
||||
import { hexToRgba } from '@/lib/colors';
|
||||
@@ -5,15 +6,32 @@ import { getResizedImageURL } from '@/lib/images';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AdCover } from './types';
|
||||
import { Link } from '../primitives';
|
||||
|
||||
/**
|
||||
* Cover rendering for an ad.
|
||||
*/
|
||||
export async function AdCoverRendering({ ad }: { ad: AdCover }) {
|
||||
export async function AdCoverRendering({
|
||||
ad,
|
||||
insightsAd,
|
||||
}: {
|
||||
ad: AdCover;
|
||||
insightsAd: SiteInsightsAd | null;
|
||||
}) {
|
||||
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
|
||||
|
||||
return (
|
||||
<a
|
||||
<Link
|
||||
rel="sponsored noopener"
|
||||
target="_blank"
|
||||
insights={
|
||||
insightsAd
|
||||
? {
|
||||
type: 'ad_click',
|
||||
ad: insightsAd,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={tcls(
|
||||
'group/ad',
|
||||
'relative',
|
||||
@@ -33,8 +51,6 @@ export async function AdCoverRendering({ ad }: { ad: AdCover }) {
|
||||
)}
|
||||
style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }}
|
||||
href={ad.statlink}
|
||||
rel="sponsored noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -106,6 +122,6 @@ export async function AdCoverRendering({ ad }: { ad: AdCover }) {
|
||||
backgroundColor: hexToRgba(ad.backgroundColor, 0.8),
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { SiteInsightsAd, SiteInsightsAdPlacement } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { AdClassicRendering } from './AdClassicRendering';
|
||||
@@ -20,7 +21,7 @@ interface FetchLiveAdOptions {
|
||||
/** Mode to render the Ad */
|
||||
mode: 'classic' | 'auto' | 'cover';
|
||||
/** Name of the placement for the ad */
|
||||
placement: string;
|
||||
placement: SiteInsightsAdPlacement;
|
||||
/** If true, we'll not track it as an impression */
|
||||
ignore: boolean;
|
||||
}
|
||||
@@ -47,16 +48,28 @@ export async function renderAd(options: FetchAdOptions) {
|
||||
|
||||
const { ad } = result;
|
||||
|
||||
return (
|
||||
<>
|
||||
{mode === 'classic' || !('callToAction' in ad) ? (
|
||||
<AdClassicRendering ad={ad} />
|
||||
) : (
|
||||
<AdCoverRendering ad={ad} />
|
||||
)}
|
||||
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
|
||||
</>
|
||||
);
|
||||
const insightsAd: SiteInsightsAd | null =
|
||||
options.source === 'live'
|
||||
? {
|
||||
placement: options.placement,
|
||||
zoneId: options.zoneId,
|
||||
domain: 'company' in ad ? ad.company : '',
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
children: (
|
||||
<>
|
||||
{mode === 'classic' || !('callToAction' in ad) ? (
|
||||
<AdClassicRendering ad={ad} insightsAd={insightsAd} />
|
||||
) : (
|
||||
<AdCoverRendering ad={ad} insightsAd={insightsAd} />
|
||||
)}
|
||||
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
|
||||
</>
|
||||
),
|
||||
insightsAd,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAd({
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Blocks } from '../Blocks';
|
||||
import { InlineProps } from '../Inline';
|
||||
import { Inlines } from '../Inlines';
|
||||
|
||||
export async function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
|
||||
export function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
|
||||
const { inline, context, document, children } = props;
|
||||
|
||||
const fragment = getNodeFragmentByType(inline, 'annotation-body');
|
||||
|
||||
@@ -35,8 +35,11 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
|
||||
title={resolved.text}
|
||||
style={style}
|
||||
insights={{
|
||||
target: block.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: block.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { DocumentBlockCode } from '@gitbook/api';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { HighlightLine, RenderedInline } from './highlight';
|
||||
import type { BlockProps } from '../Block';
|
||||
import './theme.css';
|
||||
import { ClientCodeBlockRenderer } from './CodeBlockRenderer';
|
||||
import { highlightAction } from './highlight-action';
|
||||
import { plainHighlight } from './plain-highlight';
|
||||
|
||||
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
inlines: RenderedInline[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a code-block client-side by calling a server actions to highlight the code.
|
||||
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
|
||||
*/
|
||||
export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
const { block, style, inlines } = props;
|
||||
const [lines, setLines] = useState<HighlightLine[]>(() => plainHighlight(block));
|
||||
useEffect(() => {
|
||||
highlightAction(block, inlines).then(setLines);
|
||||
}, [block, inlines]);
|
||||
return <ClientCodeBlockRenderer block={block} style={style} lines={lines} />;
|
||||
}
|
||||
@@ -1,291 +1,43 @@
|
||||
import { DocumentBlockCode, JSONDocument } from '@gitbook/api';
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getNodeFragmentByType } from '@/lib/document';
|
||||
|
||||
import { CopyCodeButton } from './CopyCodeButton';
|
||||
import { highlight, HighlightLine, HighlightToken, plainHighlighting } from './highlight';
|
||||
import { BlockProps } from '../Block';
|
||||
import { DocumentContext } from '../DocumentView';
|
||||
import { Inline } from '../Inline';
|
||||
|
||||
import './theme.css';
|
||||
import { ClientCodeBlock } from './ClientCodeBlock';
|
||||
import { getInlines, RenderedInline } from './highlight';
|
||||
import { Blocks } from '../Blocks';
|
||||
import { ServerCodeBlock } from './ServerCodeBlock';
|
||||
|
||||
/**
|
||||
* Render an entire code-block. The syntax highlighting is done server-side.
|
||||
* Render a code block, can be client-side or server-side.
|
||||
*/
|
||||
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
|
||||
const { block, document, style, context } = props;
|
||||
const lines = await highlight(block);
|
||||
|
||||
const id = block.key!;
|
||||
|
||||
const withLineNumbers = !!block.data.lineNumbers && block.nodes.length > 1;
|
||||
const withWrap = block.data.overflow === 'wrap';
|
||||
const title = block.data.title;
|
||||
const titleRoundingStyle = [
|
||||
'rounded-md',
|
||||
'straight-corners:rounded-sm',
|
||||
title ? 'rounded-ss-none' : null,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={tcls('group/codeblock', 'grid', 'grid-flow-col', style)}>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-start',
|
||||
'[grid-area:1/1]',
|
||||
'text-sm',
|
||||
'gap-2',
|
||||
)}
|
||||
>
|
||||
{title ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'text-xs',
|
||||
'tracking-wide',
|
||||
'text-dark/7',
|
||||
'leading-none',
|
||||
'inline-flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'bg-light-2',
|
||||
'rounded-t',
|
||||
'straight-corners:rounded-t-s',
|
||||
'px-3',
|
||||
'py-2',
|
||||
'dark:bg-dark-2',
|
||||
'dark:text-light/7',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<CopyCodeButton
|
||||
codeId={id}
|
||||
style={[
|
||||
'group-hover/codeblock:opacity-[1]',
|
||||
'transition-opacity',
|
||||
'duration-75',
|
||||
'opacity-0',
|
||||
'text-xs',
|
||||
'[grid-area:2/1]',
|
||||
'z-[2]',
|
||||
'justify-self-end',
|
||||
'backdrop-blur-md',
|
||||
'leading-none',
|
||||
'self-start',
|
||||
'ring-1',
|
||||
'ring-dark/2',
|
||||
'text-dark/7',
|
||||
'bg-transparent',
|
||||
'rounded-md',
|
||||
'mr-2',
|
||||
'mt-2',
|
||||
'p-1',
|
||||
'hover:ring-dark/3',
|
||||
'dark:ring-light/2',
|
||||
'dark:text-light/7',
|
||||
'dark:hover:ring-light/3',
|
||||
]}
|
||||
/>
|
||||
<pre
|
||||
className={tcls(
|
||||
'[grid-area:2/1]',
|
||||
'relative',
|
||||
'overflow-auto',
|
||||
'bg-light-2',
|
||||
'dark:bg-dark-2',
|
||||
'border-light-4',
|
||||
'dark:border-dark-4',
|
||||
'hide-scroll',
|
||||
titleRoundingStyle,
|
||||
)}
|
||||
>
|
||||
<code
|
||||
id={id}
|
||||
className={tcls(
|
||||
'min-w-full',
|
||||
'inline-grid',
|
||||
'[grid-template-columns:auto_1fr]',
|
||||
'py-2',
|
||||
'px-2',
|
||||
'[counter-reset:line]',
|
||||
withWrap ? 'whitespace-pre-wrap' : '',
|
||||
)}
|
||||
>
|
||||
{lines.map((line, index) => (
|
||||
<CodeHighlightLine
|
||||
block={block}
|
||||
document={document}
|
||||
key={index}
|
||||
line={line}
|
||||
lineIndex={index + 1}
|
||||
isLast={index === lines.length - 1}
|
||||
withLineNumbers={withLineNumbers}
|
||||
withWrap={withWrap}
|
||||
context={context}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeHighlightLine(props: {
|
||||
block: DocumentBlockCode;
|
||||
document: JSONDocument;
|
||||
line: HighlightLine;
|
||||
lineIndex: number;
|
||||
isLast: boolean;
|
||||
withLineNumbers: boolean;
|
||||
withWrap: boolean;
|
||||
context: DocumentContext;
|
||||
}) {
|
||||
const { block, document, line, isLast, withLineNumbers, context } = props;
|
||||
return (
|
||||
<span
|
||||
className={tcls(
|
||||
'grid',
|
||||
'[grid-template-columns:subgrid]',
|
||||
'col-span-2',
|
||||
'relative',
|
||||
'ring-1',
|
||||
'ring-transparent',
|
||||
'hover:ring-dark-4/5',
|
||||
'hover:z-[1]',
|
||||
'dark:hover:ring-light-4/4',
|
||||
'rounded',
|
||||
//first child
|
||||
'[&.highlighted:first-child]:rounded-t-md',
|
||||
'[&.highlighted:first-child>*]:mt-1',
|
||||
//last child
|
||||
'[&.highlighted:last-child]:rounded-b-md',
|
||||
'[&.highlighted:last-child>*]:mb-1',
|
||||
//is only child, dont hover effect line
|
||||
'[&:only-child]:hover:ring-transparent',
|
||||
//select all highlighted
|
||||
'[&.highlighted]:rounded-none',
|
||||
//select first in group
|
||||
'[&:not(.highlighted)_+_.highlighted]:rounded-t-md',
|
||||
'[&:not(.highlighted)_+_.highlighted>*]:mt-1',
|
||||
//select last in group
|
||||
'[&.highlighted:has(+:not(.highlighted))]:rounded-b-md',
|
||||
'[&.highlighted:has(+:not(.highlighted))>*]:mb-1',
|
||||
//select if highlight is singular in group
|
||||
'[&:not(.highlighted)_+_.highlighted:has(+:not(.highlighted))]:rounded-md',
|
||||
|
||||
line.highlighted ? ['highlighted', 'bg-light-3', 'dark:bg-dark-3'] : null,
|
||||
)}
|
||||
>
|
||||
{withLineNumbers ? (
|
||||
<span
|
||||
className={tcls(
|
||||
'text-sm',
|
||||
'text-right',
|
||||
'pr-3.5',
|
||||
'rounded-l',
|
||||
'pl-2',
|
||||
'sticky',
|
||||
'left-[-3px]',
|
||||
'bg-gradient-to-r',
|
||||
'from-80%',
|
||||
'from-light-2',
|
||||
'to-transparent',
|
||||
'dark:from-dark-2',
|
||||
'dark:to-transparent',
|
||||
withLineNumbers
|
||||
? [
|
||||
'before:text-dark/5',
|
||||
'before:content-[counter(line)]',
|
||||
'[counter-increment:line]',
|
||||
'dark:before:text-light/4',
|
||||
|
||||
line.highlighted
|
||||
? [
|
||||
'before:text-dark/6',
|
||||
'dark:before:text-light/8',
|
||||
'bg-gradient-to-r',
|
||||
'from-80%',
|
||||
'from-light-3',
|
||||
'to-transparent',
|
||||
'dark:from-dark-3',
|
||||
'dark:to-transparent',
|
||||
]
|
||||
: null,
|
||||
]
|
||||
: [],
|
||||
)}
|
||||
></span>
|
||||
) : null}
|
||||
|
||||
<span className={tcls('ml-3', 'block', 'text-sm')}>
|
||||
<CodeHighlightTokens tokens={line.tokens} document={document} context={context} />
|
||||
{isLast ? null : !withLineNumbers && line.tokens.length === 0 && 0 ? (
|
||||
<span className="ew">{'\u200B'}</span>
|
||||
) : (
|
||||
'\n'
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeHighlightTokens(props: {
|
||||
tokens: HighlightToken[];
|
||||
document: JSONDocument;
|
||||
context: DocumentContext;
|
||||
}) {
|
||||
const { tokens, document, context } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{tokens.map((token, index) => (
|
||||
<CodeHighlightToken
|
||||
export function CodeBlock(props: BlockProps<DocumentBlockCode>) {
|
||||
const { block, document, style, context, isEstimatedOffscreen } = props;
|
||||
const inlines = getInlines(block);
|
||||
const richInlines: RenderedInline[] = inlines.map((inline, index) => {
|
||||
const body = (() => {
|
||||
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
|
||||
if (!fragment) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Blocks
|
||||
key={index}
|
||||
token={token}
|
||||
document={document}
|
||||
ancestorBlocks={[]}
|
||||
context={context}
|
||||
nodes={fragment.nodes}
|
||||
style={['space-y-4']}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeHighlightToken(props: {
|
||||
token: HighlightToken;
|
||||
document: JSONDocument;
|
||||
context: DocumentContext;
|
||||
}) {
|
||||
const { token, document, context } = props;
|
||||
|
||||
if (token.type === 'inline') {
|
||||
return (
|
||||
<Inline
|
||||
inline={token.inline}
|
||||
document={document}
|
||||
context={context}
|
||||
ancestorInlines={[]}
|
||||
>
|
||||
<CodeHighlightTokens
|
||||
tokens={token.children}
|
||||
document={document}
|
||||
context={context}
|
||||
/>
|
||||
</Inline>
|
||||
);
|
||||
}
|
||||
|
||||
if (token.type === 'plain') {
|
||||
return <>{token.content}</>;
|
||||
}
|
||||
|
||||
if (!token.token.color) {
|
||||
return <>{token.token.content}</>;
|
||||
}
|
||||
|
||||
return <span style={{ color: token.token.color }}>{token.token.content}</span>;
|
||||
);
|
||||
})();
|
||||
|
||||
return { inline, body };
|
||||
});
|
||||
|
||||
if (isEstimatedOffscreen) {
|
||||
return <ClientCodeBlock block={block} style={style} inlines={richInlines} />;
|
||||
}
|
||||
|
||||
return <ServerCodeBlock block={block} style={style} inlines={richInlines} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
.highlight-line {
|
||||
@apply grid [grid-template-columns:subgrid] col-span-2 relative ring-1 ring-transparent hover:ring-dark-4/5 hover:z-[1] dark:hover:ring-light-4/4 rounded;
|
||||
@apply only:hover:ring-transparent;
|
||||
|
||||
&.highlighted {
|
||||
@apply bg-light-3 dark:bg-dark-3;
|
||||
@apply first:rounded-t-md *:first:mt-1;
|
||||
@apply last:rounded-b-md *:last:mb-1;
|
||||
@apply rounded-none;
|
||||
}
|
||||
|
||||
&:not(.highlighted) + .highlighted {
|
||||
@apply rounded-t-md *:mt-1;
|
||||
}
|
||||
|
||||
&.highlighted:has(+ :not(.highlighted)) {
|
||||
@apply rounded-b-md *:mb-1;
|
||||
}
|
||||
|
||||
&:not(.highlighted) + .highlighted:has(+ :not(.highlighted)) {
|
||||
@apply rounded-md;
|
||||
}
|
||||
}
|
||||
|
||||
.highlight-line-number {
|
||||
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-light-2 to-transparent dark:from-dark-2 dark:to-transparent;
|
||||
@apply before:text-dark/5 before:content-[counter(line)] [counter-increment:line] dark:before:text-light/4;
|
||||
|
||||
&.highlighted {
|
||||
@apply before:text-dark/6 dark:before:text-light/8 bg-gradient-to-r from-80% from-light-3 to-transparent dark:from-dark-3 dark:to-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.highlight-line-content {
|
||||
@apply ml-3 block text-sm;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { DocumentBlockCode, JSONDocument } from '@gitbook/api';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { CopyCodeButton } from './CopyCodeButton';
|
||||
import type { HighlightLine, HighlightToken } from './highlight';
|
||||
import { AnnotationPopover } from '../Annotation/AnnotationPopover';
|
||||
import { BlockProps } from '../Block';
|
||||
import './theme.css';
|
||||
import './CodeBlockRenderer.css';
|
||||
|
||||
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
lines: HighlightLine[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The logic of rendering a code block from lines.
|
||||
*/
|
||||
export function ClientCodeBlockRenderer(props: CodeBlockRendererProps) {
|
||||
const { block, style, lines } = props;
|
||||
|
||||
const id = block.key!;
|
||||
|
||||
const withLineNumbers = !!block.data.lineNumbers && block.nodes.length > 1;
|
||||
const withWrap = block.data.overflow === 'wrap';
|
||||
const title = block.data.title;
|
||||
const titleRoundingStyle = [
|
||||
'rounded-md',
|
||||
'straight-corners:rounded-sm',
|
||||
title ? 'rounded-ss-none' : null,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={tcls('group/codeblock grid grid-flow-col', style)}>
|
||||
<div className="flex items-center justify-start [grid-area:1/1] text-sm gap-2">
|
||||
{title ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'text-xs',
|
||||
'tracking-wide',
|
||||
'text-dark/7',
|
||||
'leading-none',
|
||||
'inline-flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'bg-light-2',
|
||||
'rounded-t',
|
||||
'straight-corners:rounded-t-s',
|
||||
'px-3',
|
||||
'py-2',
|
||||
'dark:bg-dark-2',
|
||||
'dark:text-light/7',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<CopyCodeButton
|
||||
codeId={id}
|
||||
style={[
|
||||
'group-hover/codeblock:opacity-[1]',
|
||||
'transition-opacity',
|
||||
'duration-75',
|
||||
'opacity-0',
|
||||
'text-xs',
|
||||
'[grid-area:2/1]',
|
||||
'z-[2]',
|
||||
'justify-self-end',
|
||||
'backdrop-blur-md',
|
||||
'leading-none',
|
||||
'self-start',
|
||||
'ring-1',
|
||||
'ring-dark/2',
|
||||
'text-dark/7',
|
||||
'bg-transparent',
|
||||
'rounded-md',
|
||||
'mr-2',
|
||||
'mt-2',
|
||||
'p-1',
|
||||
'hover:ring-dark/3',
|
||||
'dark:ring-light/2',
|
||||
'dark:text-light/7',
|
||||
'dark:hover:ring-light/3',
|
||||
]}
|
||||
/>
|
||||
<pre
|
||||
className={tcls(
|
||||
'[grid-area:2/1]',
|
||||
'relative',
|
||||
'overflow-auto',
|
||||
'bg-light-2',
|
||||
'dark:bg-dark-2',
|
||||
'border-light-4',
|
||||
'dark:border-dark-4',
|
||||
'hide-scroll',
|
||||
titleRoundingStyle,
|
||||
)}
|
||||
>
|
||||
<code
|
||||
id={id}
|
||||
className={tcls(
|
||||
'min-w-full',
|
||||
'inline-grid',
|
||||
'[grid-template-columns:auto_1fr]',
|
||||
'py-2',
|
||||
'px-2',
|
||||
'[counter-reset:line]',
|
||||
withWrap ? 'whitespace-pre-wrap' : '',
|
||||
)}
|
||||
>
|
||||
{lines.map((line, index) => (
|
||||
<CodeHighlightLine
|
||||
block={block}
|
||||
key={index}
|
||||
line={line}
|
||||
lineIndex={index + 1}
|
||||
isLast={index === lines.length - 1}
|
||||
withLineNumbers={withLineNumbers}
|
||||
withWrap={withWrap}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeHighlightLine(props: {
|
||||
block: DocumentBlockCode;
|
||||
line: HighlightLine;
|
||||
lineIndex: number;
|
||||
isLast: boolean;
|
||||
withLineNumbers: boolean;
|
||||
withWrap: boolean;
|
||||
}) {
|
||||
const { line, isLast, withLineNumbers } = props;
|
||||
return (
|
||||
<span className={tcls('highlight-line', line.highlighted && 'highlighted')}>
|
||||
{withLineNumbers ? (
|
||||
<span
|
||||
className={tcls('highlight-line-number', line.highlighted && 'highlighted')}
|
||||
></span>
|
||||
) : null}
|
||||
|
||||
<span className="highlight-line-content">
|
||||
<CodeHighlightTokens tokens={line.tokens} />
|
||||
{isLast ? null : !withLineNumbers && line.tokens.length === 0 && 0 ? (
|
||||
<span className="ew">{'\u200B'}</span>
|
||||
) : (
|
||||
'\n'
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeHighlightTokens(props: { tokens: HighlightToken[] }) {
|
||||
const { tokens } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{tokens.map((token, index) => (
|
||||
<CodeHighlightToken key={index} token={token} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeHighlightToken(props: { token: HighlightToken }) {
|
||||
const { token } = props;
|
||||
|
||||
if (token.type === 'annotation') {
|
||||
return (
|
||||
<AnnotationPopover body={token.body}>
|
||||
<CodeHighlightTokens tokens={token.children} />
|
||||
</AnnotationPopover>
|
||||
);
|
||||
}
|
||||
|
||||
if (token.type === 'plain') {
|
||||
return <>{token.content}</>;
|
||||
}
|
||||
|
||||
if (!token.token.color) {
|
||||
return <>{token.token.content}</>;
|
||||
}
|
||||
|
||||
return <span style={{ color: token.token.color }}>{token.token.content}</span>;
|
||||
}
|
||||
@@ -53,7 +53,8 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
|
||||
}}
|
||||
block={block}
|
||||
ancestorBlocks={[]}
|
||||
isEstimatedOffscreen={false}
|
||||
// We optimize perf by default
|
||||
isEstimatedOffscreen
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
import { highlight, RenderedInline } from './highlight';
|
||||
import type { BlockProps } from '../Block';
|
||||
import './theme.css';
|
||||
import { ClientCodeBlockRenderer } from './CodeBlockRenderer';
|
||||
|
||||
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
inlines: RenderedInline[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a code-block server-side.
|
||||
*/
|
||||
export async function ServerCodeBlock(props: ClientBlockProps) {
|
||||
const { block, style, inlines } = props;
|
||||
const lines = await highlight(block, inlines);
|
||||
return <ClientCodeBlockRenderer block={block} style={style} lines={lines} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use server';
|
||||
|
||||
import { DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
import { highlight, RenderedInline } from './highlight';
|
||||
|
||||
/**
|
||||
* Server action to highlight a code block.
|
||||
* By using a server action, we can avoid loading the highlighter on the client-side
|
||||
* and increasing the bundle size.
|
||||
*/
|
||||
export async function highlightAction(block: DocumentBlockCode, inlines: RenderedInline[]) {
|
||||
return highlight(block, inlines);
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
import { it, expect } from 'bun:test';
|
||||
|
||||
import { highlight } from './highlight';
|
||||
import { getInlines, highlight, RenderedInline } from './highlight';
|
||||
|
||||
async function highlightWithInlines(block: DocumentBlockCode) {
|
||||
const inlines: RenderedInline[] = getInlines(block).map((inline) => ({
|
||||
inline,
|
||||
body: null,
|
||||
}));
|
||||
return highlight(block, inlines);
|
||||
}
|
||||
|
||||
it('should parse plain code', async () => {
|
||||
const tokens = await highlight({
|
||||
const tokens = await highlightWithInlines({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {},
|
||||
@@ -38,32 +47,35 @@ it('should parse plain code', async () => {
|
||||
it('should parse different code in parallel', async () => {
|
||||
await Promise.all(
|
||||
['shell', 'scss', 'markdown', 'less', 'scss', 'css', 'scss', 'yaml'].map(async (syntax) =>
|
||||
highlight({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {
|
||||
syntax: syntax,
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'code-line',
|
||||
data: {},
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }],
|
||||
},
|
||||
],
|
||||
highlight(
|
||||
{
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {
|
||||
syntax: syntax,
|
||||
},
|
||||
],
|
||||
}),
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'code-line',
|
||||
data: {},
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
[],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse a multilines plain code', async () => {
|
||||
const tokens = await highlight({
|
||||
const tokens = await highlightWithInlines({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {},
|
||||
@@ -138,7 +150,7 @@ it('should parse a multilines plain code', async () => {
|
||||
});
|
||||
|
||||
it('should parse code with an inline on a single line', async () => {
|
||||
const tokens = await highlight({
|
||||
const tokens = await highlightWithInlines({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {
|
||||
@@ -192,10 +204,8 @@ it('should parse code with an inline on a single line', async () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'inline',
|
||||
inline: {
|
||||
type: 'annotation',
|
||||
},
|
||||
type: 'annotation',
|
||||
body: null,
|
||||
children: [
|
||||
{
|
||||
type: 'shiki',
|
||||
@@ -229,7 +239,7 @@ it('should parse code with an inline on a single line', async () => {
|
||||
});
|
||||
|
||||
it('should parse code with an inline on a multiple line', async () => {
|
||||
const tokens = await highlight({
|
||||
const tokens = await highlightWithInlines({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {
|
||||
@@ -310,10 +320,8 @@ it('should parse code with an inline on a multiple line', async () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'inline',
|
||||
inline: {
|
||||
type: 'annotation',
|
||||
},
|
||||
type: 'annotation',
|
||||
body: null,
|
||||
children: [
|
||||
{
|
||||
type: 'shiki',
|
||||
@@ -347,10 +355,8 @@ it('should parse code with an inline on a multiple line', async () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'inline',
|
||||
inline: {
|
||||
type: 'annotation',
|
||||
},
|
||||
type: 'annotation',
|
||||
body: null,
|
||||
children: [
|
||||
{
|
||||
type: 'shiki',
|
||||
@@ -384,7 +390,7 @@ it('should parse code with an inline on a multiple line', async () => {
|
||||
});
|
||||
|
||||
it('should support code token finishing before the end of the annotation', async () => {
|
||||
const tokens = await highlight({
|
||||
const tokens = await highlightWithInlines({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
isVoid: false,
|
||||
@@ -470,10 +476,8 @@ it('should support code token finishing before the end of the annotation', async
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'inline',
|
||||
inline: {
|
||||
type: 'annotation',
|
||||
},
|
||||
type: 'annotation',
|
||||
body: null,
|
||||
children: [
|
||||
{
|
||||
type: 'shiki',
|
||||
@@ -507,7 +511,7 @@ it('should support code token finishing before the end of the annotation', async
|
||||
});
|
||||
|
||||
it('should support multiple code tokens in an annotation', async () => {
|
||||
const tokens = await highlight({
|
||||
const tokens = await highlightWithInlines({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
isVoid: false,
|
||||
@@ -615,11 +619,8 @@ it('should support multiple code tokens in an annotation', async () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'inline',
|
||||
inline: {
|
||||
object: 'inline',
|
||||
type: 'annotation',
|
||||
},
|
||||
type: 'annotation',
|
||||
body: null,
|
||||
children: [
|
||||
{
|
||||
type: 'shiki',
|
||||
@@ -653,7 +654,7 @@ it('should support multiple code tokens in an annotation', async () => {
|
||||
});
|
||||
|
||||
it('should handle \\r', async () => {
|
||||
const tokens = await highlight({
|
||||
const tokens = await highlightWithInlines({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { asyncMutexFunction, singleton } from '@/lib/async';
|
||||
import { getNodeText } from '@/lib/document';
|
||||
import { trace } from '@/lib/tracing';
|
||||
|
||||
import { plainHighlight } from './plain-highlight';
|
||||
|
||||
export type HighlightLine = {
|
||||
highlighted: boolean;
|
||||
tokens: HighlightToken[];
|
||||
@@ -20,28 +22,31 @@ export type HighlightLine = {
|
||||
export type HighlightToken =
|
||||
| { type: 'plain'; content: string }
|
||||
| { type: 'shiki'; token: ThemedToken }
|
||||
| { type: 'inline'; inline: DocumentInlineAnnotation; children: HighlightToken[] };
|
||||
| { type: 'annotation'; body: React.ReactNode; children: HighlightToken[] };
|
||||
|
||||
type InlineIndexed = { inline: any; start: number; end: number };
|
||||
export type InlineIndexed = { inline: any; start: number; end: number };
|
||||
|
||||
type PositionedToken = ThemedToken & { start: number; end: number };
|
||||
|
||||
export type RenderedInline = {
|
||||
inline: InlineIndexed;
|
||||
body: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Highlight a code block while preserving inline elements.
|
||||
*/
|
||||
export async function highlight(block: DocumentBlockCode): Promise<HighlightLine[]> {
|
||||
export async function highlight(
|
||||
block: DocumentBlockCode,
|
||||
inlines: RenderedInline[],
|
||||
): Promise<HighlightLine[]> {
|
||||
const langName = block.data.syntax ? getLanguageForSyntax(block.data.syntax) : null;
|
||||
if (!langName) {
|
||||
// Language not found, fallback to plain highlighting
|
||||
return plainHighlighting(block);
|
||||
return plainHighlight(block, inlines);
|
||||
}
|
||||
|
||||
const inlines: InlineIndexed[] = [];
|
||||
const code = getPlainCodeBlock(block, inlines);
|
||||
|
||||
inlines.sort((a, b) => {
|
||||
return a.start - b.start;
|
||||
});
|
||||
const code = getPlainCodeBlock(block);
|
||||
|
||||
const highlighter = await loadHighlighter();
|
||||
await loadHighlighterLanguage(highlighter, langName);
|
||||
@@ -116,10 +121,25 @@ function getLanguageForSyntax(syntax: string): BundledLanguage | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getInlines(block: DocumentBlockCode) {
|
||||
const inlines: InlineIndexed[] = [];
|
||||
getPlainCodeBlock(block, inlines);
|
||||
|
||||
inlines.sort((a, b) => {
|
||||
return a.start - b.start;
|
||||
});
|
||||
|
||||
return inlines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a code block without highlighting it.
|
||||
*/
|
||||
export function plainHighlighting(block: DocumentBlockCode): HighlightLine[] {
|
||||
export function plainHighlighting(
|
||||
block: DocumentBlockCode,
|
||||
inlines?: RenderedInline[],
|
||||
): HighlightLine[] {
|
||||
const inlinesCopy = Array.from(inlines ?? []);
|
||||
return block.nodes.map((lineBlock) => {
|
||||
const tokens: HighlightToken[] = [];
|
||||
|
||||
@@ -130,9 +150,10 @@ export function plainHighlighting(block: DocumentBlockCode): HighlightLine[] {
|
||||
content: getNodeText(node),
|
||||
});
|
||||
} else {
|
||||
const inline = inlinesCopy.shift();
|
||||
tokens.push({
|
||||
type: 'inline',
|
||||
inline: node,
|
||||
type: 'annotation',
|
||||
body: inline?.body ?? null,
|
||||
children: [
|
||||
{
|
||||
type: 'plain',
|
||||
@@ -152,7 +173,7 @@ export function plainHighlighting(block: DocumentBlockCode): HighlightLine[] {
|
||||
|
||||
function matchTokenAndInlines(
|
||||
eat: () => PositionedToken | null,
|
||||
allInlines: InlineIndexed[],
|
||||
allInlines: RenderedInline[],
|
||||
): HighlightToken[] {
|
||||
const initialToken = eat();
|
||||
if (!initialToken) {
|
||||
@@ -160,7 +181,7 @@ function matchTokenAndInlines(
|
||||
}
|
||||
|
||||
const inlines = allInlines.filter(
|
||||
(inline) => inline.start >= initialToken.start && inline.start < initialToken.end,
|
||||
({ inline }) => inline.start >= initialToken.start && inline.start < initialToken.end,
|
||||
);
|
||||
let token = initialToken;
|
||||
const result: HighlightToken[] = [];
|
||||
@@ -176,7 +197,7 @@ function matchTokenAndInlines(
|
||||
return;
|
||||
}
|
||||
|
||||
const [before, afterBefore] = splitPositionedTokenAt(token, inline.start);
|
||||
const [before, afterBefore] = splitPositionedTokenAt(token, inline.inline.start);
|
||||
if (before) {
|
||||
result.push({
|
||||
type: 'shiki',
|
||||
@@ -191,7 +212,7 @@ function matchTokenAndInlines(
|
||||
const children: HighlightToken[] = [];
|
||||
|
||||
// If shiki token finished before the end of the annotation or the annotation contains multiple tokens
|
||||
while (token.end < inline.end) {
|
||||
while (token.end < inline.inline.end) {
|
||||
children.push({
|
||||
type: 'shiki',
|
||||
token: token,
|
||||
@@ -204,7 +225,7 @@ function matchTokenAndInlines(
|
||||
token = next;
|
||||
}
|
||||
|
||||
const [inside, after] = splitPositionedTokenAt(token, inline.end);
|
||||
const [inside, after] = splitPositionedTokenAt(token, inline.inline.end);
|
||||
if (!inside) {
|
||||
throw new Error(`expect inside to not be empty`);
|
||||
}
|
||||
@@ -215,8 +236,8 @@ function matchTokenAndInlines(
|
||||
});
|
||||
|
||||
result.push({
|
||||
type: 'inline',
|
||||
inline: inline.inline,
|
||||
type: 'annotation',
|
||||
body: inline.body,
|
||||
children,
|
||||
});
|
||||
|
||||
@@ -230,11 +251,11 @@ function matchTokenAndInlines(
|
||||
return result;
|
||||
}
|
||||
|
||||
function getPlainCodeBlock(code: DocumentBlockCode, inlines: InlineIndexed[]): string {
|
||||
function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]): string {
|
||||
let content = '';
|
||||
|
||||
code.nodes.forEach((node, index) => {
|
||||
const lineContent = getPlainCodeBlockLine(node, inlines, content.length);
|
||||
const lineContent = getPlainCodeBlockLine(node, content.length, inlines);
|
||||
content += lineContent;
|
||||
|
||||
if (index < code.nodes.length - 1) {
|
||||
@@ -247,8 +268,8 @@ function getPlainCodeBlock(code: DocumentBlockCode, inlines: InlineIndexed[]): s
|
||||
|
||||
function getPlainCodeBlockLine(
|
||||
parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
|
||||
inlines: InlineIndexed[],
|
||||
index: number,
|
||||
inlines?: InlineIndexed[],
|
||||
): string {
|
||||
let content = '';
|
||||
|
||||
@@ -257,14 +278,16 @@ function getPlainCodeBlockLine(
|
||||
content += cleanupLine(node.leaves.map((leaf) => leaf.text).join(''));
|
||||
} else {
|
||||
const start = index + content.length;
|
||||
content += getPlainCodeBlockLine(node, inlines, index + content.length);
|
||||
content += getPlainCodeBlockLine(node, index + content.length, inlines);
|
||||
const end = index + content.length;
|
||||
|
||||
inlines.push({
|
||||
inline: node,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
if (inlines) {
|
||||
inlines.push({
|
||||
inline: node,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +364,8 @@ const loadHighlighter = singleton(async () => {
|
||||
});
|
||||
|
||||
const loadLanguagesMutex = asyncMutexFunction();
|
||||
async function loadHighlighterLanguage(
|
||||
|
||||
const loadHighlighterLanguage = async function loadHighlighterLanguage(
|
||||
highlighter: HighlighterGeneric<any, any>,
|
||||
lang: keyof typeof bundledLanguages,
|
||||
) {
|
||||
@@ -355,4 +379,4 @@ async function loadHighlighterLanguage(
|
||||
async () => await highlighter.loadLanguage(lang),
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
import { getNodeText } from '@/lib/document';
|
||||
|
||||
import type { HighlightLine, HighlightToken, RenderedInline } from './highlight';
|
||||
|
||||
/**
|
||||
* Parse a code block without highlighting it.
|
||||
*/
|
||||
export function plainHighlight(
|
||||
block: DocumentBlockCode,
|
||||
inlines?: RenderedInline[],
|
||||
): HighlightLine[] {
|
||||
const inlinesCopy = Array.from(inlines ?? []);
|
||||
return block.nodes.map((lineBlock) => {
|
||||
const tokens: HighlightToken[] = [];
|
||||
|
||||
for (const node of lineBlock.nodes) {
|
||||
if (node.object === 'text') {
|
||||
tokens.push({
|
||||
type: 'plain',
|
||||
content: getNodeText(node),
|
||||
});
|
||||
} else {
|
||||
const inline = inlinesCopy.shift();
|
||||
tokens.push({
|
||||
type: 'annotation',
|
||||
body: inline?.body ?? null,
|
||||
children: [
|
||||
{
|
||||
type: 'plain',
|
||||
content: getNodeText(node),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
highlighted: !!lineBlock.data.highlighted,
|
||||
tokens,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -26,8 +26,11 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
|
||||
href={file.downloadURL}
|
||||
download={file.name}
|
||||
insights={{
|
||||
target: block.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: block.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
}}
|
||||
className={tcls(
|
||||
'group/file',
|
||||
|
||||
@@ -27,8 +27,11 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
||||
href={resolved.href}
|
||||
className="underline underline-offset-2 text-primary hover:text-primary-700 transition-colors"
|
||||
insights={{
|
||||
target: inline.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: inline.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Inlines
|
||||
|
||||
@@ -19,8 +19,11 @@ export async function Mention(props: InlineProps<DocumentInlineMention>) {
|
||||
<StyledLink
|
||||
href={resolved.href}
|
||||
insights={{
|
||||
target: inline.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: inline.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{resolved.text}
|
||||
|
||||
@@ -17,12 +17,10 @@ import './scalar.css';
|
||||
* Render an OpenAPI block.
|
||||
*/
|
||||
export async function OpenAPI(props: BlockProps<DocumentBlockOpenAPI>) {
|
||||
const { block, style } = props;
|
||||
const { style } = props;
|
||||
return (
|
||||
<div className={tcls('w-full', 'flex', 'flex-row', style, 'max-w-full')}>
|
||||
<React.Suspense fallback={<OpenAPIFallback />}>
|
||||
<OpenAPIBody {...props} />
|
||||
</React.Suspense>
|
||||
<OpenAPIBody {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,8 +166,11 @@ export async function RecordCard(
|
||||
'dark:hover:before:ring-light/4',
|
||||
])}
|
||||
insights={{
|
||||
target: targetRef,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: targetRef,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
|
||||
@@ -168,11 +168,14 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
insights={
|
||||
ref.file
|
||||
? {
|
||||
target: {
|
||||
kind: 'file',
|
||||
file: ref.file.id,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: {
|
||||
kind: 'file',
|
||||
file: ref.file.id,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -225,8 +228,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
insights={
|
||||
contentRef
|
||||
? {
|
||||
target: contentRef,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: contentRef,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -260,8 +266,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
key={index}
|
||||
href={resolved.href}
|
||||
insights={{
|
||||
target: contentRef,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: contentRef,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{resolved.text}
|
||||
|
||||
@@ -130,10 +130,10 @@ const textColorToStyle: { [color in DocumentMarkColor['data']['text']]: ClassVal
|
||||
|
||||
const backgroundColorToStyle: { [color in DocumentMarkColor['data']['background']]: ClassValue } = {
|
||||
default: [],
|
||||
blue: ['bg-blue-200', 'dark:bg-blue-900'],
|
||||
red: ['bg-red-200', 'dark:bg-red-900'],
|
||||
green: ['bg-green-200', 'dark:bg-green-900'],
|
||||
yellow: ['bg-yellow-100', 'dark:bg-yellow-900'],
|
||||
purple: ['bg-purple-200', 'dark:bg-purple-900'],
|
||||
orange: ['bg-orange-200', 'dark:bg-orange-900'],
|
||||
blue: ['bg-mark-blue'],
|
||||
red: ['bg-mark-red'],
|
||||
green: ['bg-mark-green'],
|
||||
yellow: ['bg-mark-yellow'],
|
||||
purple: ['bg-mark-purple'],
|
||||
orange: ['bg-mark-orange'],
|
||||
};
|
||||
|
||||
@@ -54,8 +54,11 @@ async function FooterLink(props: { link: CustomizationContentLink; context: Cont
|
||||
'underline-offset-2',
|
||||
)}
|
||||
insights={{
|
||||
target: link.to,
|
||||
position: SiteInsightsLinkPosition.Footer,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: link.to,
|
||||
position: SiteInsightsLinkPosition.Footer,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{link.title}
|
||||
|
||||
@@ -145,8 +145,11 @@ function HeaderItemButton(
|
||||
}[linkStyle],
|
||||
)}
|
||||
insights={{
|
||||
target: linkTarget,
|
||||
position: SiteInsightsLinkPosition.Header,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: linkTarget,
|
||||
position: SiteInsightsLinkPosition.Header,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
@@ -175,8 +178,11 @@ function HeaderItemLink(props: Omit<HeaderLinkNavItemProps, 'linkStyle'>) {
|
||||
href={href}
|
||||
className={getHeaderLinkClassName({ headerPreset })}
|
||||
insights={{
|
||||
target: linkTarget,
|
||||
position: SiteInsightsLinkPosition.Header,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: linkTarget,
|
||||
position: SiteInsightsLinkPosition.Header,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
@@ -219,7 +225,13 @@ async function SubHeaderLink(props: {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
href={target.href}
|
||||
insights={{ target: link.to, position: SiteInsightsLinkPosition.Header }}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: link.to,
|
||||
position: SiteInsightsLinkPosition.Header,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{link.title}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -82,8 +82,11 @@ async function MoreMenuLink(props: {
|
||||
insights={
|
||||
link.to
|
||||
? {
|
||||
target: link.to,
|
||||
position: SiteInsightsLinkPosition.Header,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: link.to,
|
||||
position: SiteInsightsLinkPosition.Header,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as cookies from '@/lib/cookies';
|
||||
import { getSession } from './sessions';
|
||||
import { getVisitorId } from './visitorId';
|
||||
|
||||
type SiteEventName = api.SiteInsightsEvent['type'];
|
||||
export type InsightsEventName = api.SiteInsightsEvent['type'];
|
||||
|
||||
/**
|
||||
* Global context for all events in the session.
|
||||
@@ -46,15 +46,14 @@ interface InsightsEventOptions {
|
||||
/**
|
||||
* Input data for an event.
|
||||
*/
|
||||
type TrackEventInput<EventName extends SiteEventName> = { type: EventName } & Omit<
|
||||
Extract<api.SiteInsightsEvent, { type: EventName }>,
|
||||
'location' | 'session'
|
||||
>;
|
||||
export type TrackEventInput<EventName extends InsightsEventName = InsightsEventName> = {
|
||||
type: EventName;
|
||||
} & Omit<Extract<api.SiteInsightsEvent, { type: EventName }>, 'location' | 'session'>;
|
||||
|
||||
/**
|
||||
* Callback to track an event.
|
||||
*/
|
||||
type TrackEventCallback = <EventName extends SiteEventName>(
|
||||
type TrackEventCallback = <EventName extends InsightsEventName>(
|
||||
event: TrackEventInput<EventName>,
|
||||
ctx?: InsightsEventPageContext,
|
||||
options?: InsightsEventOptions,
|
||||
@@ -80,7 +79,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
[pathname: string]:
|
||||
| {
|
||||
url: string;
|
||||
events: TrackEventInput<SiteEventName>[];
|
||||
events: TrackEventInput<InsightsEventName>[];
|
||||
context: InsightsEventContext;
|
||||
pageContext?: InsightsEventPageContext;
|
||||
}
|
||||
@@ -152,7 +151,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
|
||||
const trackEvent: TrackEventCallback = useEventCallback(
|
||||
(
|
||||
event: TrackEventInput<SiteEventName>,
|
||||
event: TrackEventInput<InsightsEventName>,
|
||||
ctx?: InsightsEventPageContext,
|
||||
options?: InsightsEventOptions,
|
||||
) => {
|
||||
@@ -253,7 +252,7 @@ function sendEvents(args: {
|
||||
*/
|
||||
function transformEvents(input: {
|
||||
url: string;
|
||||
events: TrackEventInput<SiteEventName>[];
|
||||
events: TrackEventInput<InsightsEventName>[];
|
||||
context: InsightsEventContext;
|
||||
pageContext: InsightsEventPageContext;
|
||||
visitorId: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Site,
|
||||
SiteAdsStatus,
|
||||
SiteCustomizationSettings,
|
||||
SiteInsightsAdPlacement,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
@@ -12,7 +13,7 @@ import React from 'react';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
import { t, getSpaceLanguage } from '@/intl/server';
|
||||
import { getDocumentSections } from '@/lib/document';
|
||||
import { getDocumentSections } from '@/lib/document-sections';
|
||||
import { getAbsoluteHref } from '@/lib/links';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -137,6 +138,7 @@ export async function PageAside(props: {
|
||||
<div
|
||||
className={tcls(
|
||||
'overflow-auto',
|
||||
'overflow-x-visible',
|
||||
'flex-1',
|
||||
'flex',
|
||||
'flex-col',
|
||||
@@ -186,7 +188,7 @@ export async function PageAside(props: {
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'text-sm',
|
||||
'hover:text-primary',
|
||||
'hover:text-tint',
|
||||
'py-2',
|
||||
)}
|
||||
>
|
||||
@@ -211,7 +213,7 @@ export async function PageAside(props: {
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'text-sm',
|
||||
'hover:text-primary',
|
||||
'hover:text-tint',
|
||||
'py-2',
|
||||
)}
|
||||
>
|
||||
@@ -226,7 +228,7 @@ export async function PageAside(props: {
|
||||
zoneId={
|
||||
site?.ads && site.ads.status === SiteAdsStatus.Live ? site.ads.zoneId : null
|
||||
}
|
||||
placement="page.aside"
|
||||
placement={SiteInsightsAdPlacement.Aside}
|
||||
spaceId={space.id}
|
||||
siteAdsStatus={site?.ads && site.ads.status ? site.ads.status : undefined}
|
||||
ignore={process.env.NODE_ENV !== 'production'}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
import { useScrollActiveId } from '@/components/hooks';
|
||||
import { DocumentSection } from '@/lib/document';
|
||||
import type { DocumentSection } from '@/lib/document-sections';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AsideSectionHighlight } from './AsideSectionHighlight';
|
||||
@@ -53,11 +53,11 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
|
||||
<AsideSectionHighlight
|
||||
transition={springCurve}
|
||||
className={tcls(
|
||||
'sidebar-list-default:hidden',
|
||||
section?.depth > 1
|
||||
? [
|
||||
'sidebar-list-default:rounded-l-none',
|
||||
'sidebar-list-line:rounded-l-none',
|
||||
'sidebar-list-default:border-l',
|
||||
]
|
||||
: [
|
||||
'sidebar-list-default:ml-3',
|
||||
@@ -113,6 +113,8 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
|
||||
|
||||
'contrast-more:font-semibold',
|
||||
|
||||
'sidebar-list-default:border-tint',
|
||||
|
||||
'hover:bg-tint/3',
|
||||
'dark:hover:bg-tint-400/3',
|
||||
'sidebar-list-pill:hover:bg-transparent',
|
||||
|
||||
@@ -120,27 +120,30 @@ export function PageBody(props: {
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'flex-wrap',
|
||||
'gap-4',
|
||||
'items-center',
|
||||
'mt-6',
|
||||
'max-w-3xl',
|
||||
'mx-auto',
|
||||
'page-api-block:ml-0',
|
||||
'text-dark/7',
|
||||
'dark:text-light/7',
|
||||
'contrast-more:text-dark',
|
||||
'dark:contrast-more:text-light',
|
||||
)}
|
||||
>
|
||||
{updatedAt ? (
|
||||
<p
|
||||
className={tcls(
|
||||
'flex-1',
|
||||
'text-sm',
|
||||
'text-dark/6',
|
||||
'dark:text-light/5',
|
||||
)}
|
||||
>
|
||||
<p className={tcls('text-sm mr-auto')}>
|
||||
{t(language, 'page_last_modified', <DateRelative value={updatedAt} />)}
|
||||
</p>
|
||||
) : null}
|
||||
{withPageFeedback ? (
|
||||
<PageFeedbackForm orientation="horizontal" pageId={page.id} />
|
||||
<PageFeedbackForm
|
||||
className={page.layout.outline ? 'xl:hidden' : ''}
|
||||
orientation="horizontal"
|
||||
pageId={page.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -52,8 +52,11 @@ export async function PageBodyBlankslate(props: {
|
||||
title={child.title}
|
||||
href={resolved.href}
|
||||
insights={{
|
||||
target: child.target,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: child.target,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -51,11 +51,14 @@ export async function PageFooterNavigation(props: {
|
||||
title={previous.title}
|
||||
href={previousHref}
|
||||
insights={{
|
||||
target: {
|
||||
kind: 'page',
|
||||
page: previous.id,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: {
|
||||
kind: 'page',
|
||||
page: previous.id,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
}}
|
||||
reversed
|
||||
/>
|
||||
@@ -67,11 +70,14 @@ export async function PageFooterNavigation(props: {
|
||||
title={next.title}
|
||||
href={nextHref}
|
||||
insights={{
|
||||
target: {
|
||||
kind: 'page',
|
||||
page: next.id,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: {
|
||||
kind: 'page',
|
||||
page: next.id,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { PageFeedbackRating } from '@gitbook/api';
|
||||
import React from 'react';
|
||||
import React, { ButtonHTMLAttributes } from 'react';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t, tString } from '@/intl/translate';
|
||||
@@ -9,6 +9,9 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { getVisitorId, useTrackEvent } from '../Insights';
|
||||
import { postPageFeedback } from './server-actions';
|
||||
import { Button } from '../primitives';
|
||||
|
||||
const MAX_COMMENT_LENGTH = 512;
|
||||
|
||||
/**
|
||||
* Form to submit feedback on a page.
|
||||
@@ -21,10 +24,13 @@ export function PageFeedbackForm(props: {
|
||||
const { orientation = 'vertical', pageId, className } = props;
|
||||
const languages = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
const inputRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const [rating, setRating] = React.useState<PageFeedbackRating>();
|
||||
const [comment, setComment] = React.useState('');
|
||||
const [submitted, setSubmitted] = React.useState(false);
|
||||
|
||||
const onSubmit = async (rating: PageFeedbackRating) => {
|
||||
setSubmitted(true);
|
||||
const onSubmitRating = async (rating: PageFeedbackRating) => {
|
||||
setRating(rating);
|
||||
const visitorId = await getVisitorId();
|
||||
await postPageFeedback({ pageId, visitorId, rating });
|
||||
|
||||
@@ -36,92 +42,128 @@ export function PageFeedbackForm(props: {
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmitComment = (rating: PageFeedbackRating, comment: string) => {
|
||||
setSubmitted(true);
|
||||
|
||||
trackEvent({
|
||||
type: 'page_post_feedback_comment',
|
||||
feedback: {
|
||||
rating,
|
||||
comment,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Focus the comment input when the rating is submitted
|
||||
React.useEffect(() => {
|
||||
if (!!rating) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [rating]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
orientation === 'vertical'
|
||||
? ['flex-col', 'items-start']
|
||||
: ['flex-row', 'items-center'],
|
||||
'gap-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className={tcls('text-sm')}>{t(languages, 'was_this_helpful')}</p>
|
||||
<div
|
||||
className={tcls(
|
||||
'inline-flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'flex-row',
|
||||
'rounded-full',
|
||||
'straight-corners:rounded-sm',
|
||||
'ring-1',
|
||||
'ring-inset',
|
||||
'ring-dark/2',
|
||||
'h-8',
|
||||
'dark:ring-light/1',
|
||||
)}
|
||||
>
|
||||
{submitted ? (
|
||||
<p className={tcls('text-sm', 'px-4')}>
|
||||
{t(languages, 'was_this_helpful_thank_you')}
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className={tcls(
|
||||
'inline-flex',
|
||||
'[&>*:last-child]:rounded-r-full',
|
||||
'[&>*:first-child]:rounded-l-full',
|
||||
'straight-corners:[&>*:last-child]:rounded-none',
|
||||
'straight-corners:[&>*:first-child]:rounded-none',
|
||||
)}
|
||||
>
|
||||
<div className={tcls('flex flex-col gap-3 text-sm', className)}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p>{t(languages, 'was_this_helpful')}</p>
|
||||
<div className="bg-light-1 dark:bg-dark-1 border border-dark/2 dark:border-light/2 contrast-more:border-dark dark:contrast-more:border-light rounded-full">
|
||||
<div className="flex">
|
||||
<RatingButton
|
||||
rating={0}
|
||||
rating={PageFeedbackRating.Bad}
|
||||
label={tString(languages, 'was_this_helpful_negative')}
|
||||
onClick={() => onSubmit(PageFeedbackRating.Bad)}
|
||||
onClick={() => onSubmitRating(PageFeedbackRating.Bad)}
|
||||
active={rating == PageFeedbackRating.Bad}
|
||||
disabled={rating !== undefined}
|
||||
/>
|
||||
<RatingButton
|
||||
rating={1}
|
||||
rating={PageFeedbackRating.Ok}
|
||||
label={tString(languages, 'was_this_helpful_neutral')}
|
||||
onClick={() => onSubmit(PageFeedbackRating.Ok)}
|
||||
onClick={() => onSubmitRating(PageFeedbackRating.Ok)}
|
||||
active={rating == PageFeedbackRating.Ok}
|
||||
disabled={rating !== undefined}
|
||||
/>
|
||||
<RatingButton
|
||||
rating={2}
|
||||
rating={PageFeedbackRating.Good}
|
||||
label={tString(languages, 'was_this_helpful_positive')}
|
||||
onClick={() => onSubmit(PageFeedbackRating.Good)}
|
||||
onClick={() => onSubmitRating(PageFeedbackRating.Good)}
|
||||
active={rating == PageFeedbackRating.Good}
|
||||
disabled={rating !== undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{rating ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{!submitted ? (
|
||||
<>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
name="comment"
|
||||
className="grow ring-1 ring-inset bg-light-1 dark:bg-dark-1 ring-dark/2 dark:ring-light/2 contrast-more:ring-dark dark:contrast-more:ring-light min-h-16 max-h-40 rounded straight-corners:rounded-none p-2 placeholder:text-sm placeholder:text-dark/6 dark:placeholder:text-light/6 contrast-more:placeholder:text-dark dark:contrast-more:placeholder:text-light"
|
||||
placeholder={tString(languages, 'was_this_helpful_comment')}
|
||||
aria-label={tString(languages, 'was_this_helpful_comment')}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
value={comment}
|
||||
rows={3}
|
||||
maxLength={MAX_COMMENT_LENGTH}
|
||||
/>
|
||||
<div className="flex gap-4 items-center justify-between">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => onSubmitComment(rating, comment)}
|
||||
>
|
||||
{t(languages, 'submit')}
|
||||
</Button>
|
||||
{comment.length > MAX_COMMENT_LENGTH * 0.8 ? (
|
||||
<span
|
||||
className={
|
||||
comment.length == MAX_COMMENT_LENGTH
|
||||
? 'text-red-500'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{comment.length} / {MAX_COMMENT_LENGTH}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>{t(languages, 'was_this_helpful_thank_you')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RatingButton(props: { rating: number; label: string; onClick: () => void }) {
|
||||
const { rating, label, onClick } = props;
|
||||
function RatingButton(
|
||||
props: {
|
||||
rating: PageFeedbackRating;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
active: boolean;
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
) {
|
||||
const { rating, label, onClick, active, ...attr } = props;
|
||||
|
||||
const ratingIcon =
|
||||
rating === 0 ? <Icon0 /> : rating === 1 ? <Icon1 /> : rating === 2 ? <Icon2 /> : null;
|
||||
{
|
||||
bad: <IconBad />,
|
||||
ok: <IconOk />,
|
||||
good: <IconGood />,
|
||||
}[rating] ?? null;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'h-8',
|
||||
'w-8',
|
||||
'rounded-sm',
|
||||
'text-dark/7',
|
||||
'hover:bg-tint/4',
|
||||
'hover:text-tint-600',
|
||||
'dark:text-light/7',
|
||||
'dark:hover:text-tint-300',
|
||||
'dark:hover:bg-tint-300/2',
|
||||
'p-2 hover:text-tint dark:hover:text-tint-400 hover:bg-tint/2 first:pl-2.5 last:pr-2.5 first:rounded-l-full last:rounded-r-full',
|
||||
'disabled:cursor-not-allowed disabled:hover:bg-inherit disabled:hover:text-inherit disabled:dark:hover:text-inherit',
|
||||
'contrast-more:hover:ring-1 ring-tint',
|
||||
active
|
||||
? 'bg-tint/4 text-tint-700 dark:text-tint-300 disabled:hover:bg-tint/4 disabled:hover:text-tint-700 dark:disabled:hover:text-tint-300 contrast-more:ring-2 contrast-more:hover:ring-2'
|
||||
: 'disabled:opacity-7 disabled:contrast-more:ring-0',
|
||||
)}
|
||||
type="button"
|
||||
{...attr}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
@@ -131,9 +173,9 @@ function RatingButton(props: { rating: number; label: string; onClick: () => voi
|
||||
);
|
||||
}
|
||||
|
||||
const Icon0 = () => (
|
||||
const IconBad = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.24" />
|
||||
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.2" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
@@ -142,9 +184,9 @@ const Icon0 = () => (
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const Icon1 = () => (
|
||||
const IconOk = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.24" />
|
||||
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.2" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
@@ -153,9 +195,9 @@ const Icon1 = () => (
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const Icon2 = () => (
|
||||
const IconGood = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.24" />
|
||||
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.2" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
|
||||
@@ -29,11 +29,14 @@ export async function PageDocumentItem(props: {
|
||||
href={href}
|
||||
pathname={getPagePath(rootPages, page)}
|
||||
insights={{
|
||||
target: {
|
||||
kind: 'page',
|
||||
page: page.id,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: {
|
||||
kind: 'page',
|
||||
page: page.id,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Sidebar,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Sidebar,
|
||||
}}
|
||||
descendants={
|
||||
page.pages && page.pages.length ? (
|
||||
|
||||
@@ -36,8 +36,11 @@ export async function PageLinkItem(props: { page: RevisionPageLink; context: Con
|
||||
'dark:hover:bg-light/2',
|
||||
)}
|
||||
insights={{
|
||||
target: page.target,
|
||||
position: SiteInsightsLinkPosition.Sidebar,
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: page.target,
|
||||
position: SiteInsightsLinkPosition.Sidebar,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TOCPageIcon page={page} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
RevisionPageDocument,
|
||||
RevisionPageGroup,
|
||||
SiteCustomizationSettings,
|
||||
SiteInsightsTrademarkPlacement,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import React from 'react';
|
||||
@@ -135,7 +136,11 @@ export function TableOfContents(props: {
|
||||
)}
|
||||
/>
|
||||
{customization.trademark.enabled ? (
|
||||
<Trademark space={space} customization={customization} />
|
||||
<Trademark
|
||||
space={space}
|
||||
customization={customization}
|
||||
placement={SiteInsightsTrademarkPlacement.Sidebar}
|
||||
/>
|
||||
) : null}
|
||||
</TOCScrollContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { CustomizationSettings, SiteCustomizationSettings, Space } from '@gitbook/api';
|
||||
import {
|
||||
CustomizationSettings,
|
||||
SiteCustomizationSettings,
|
||||
Space,
|
||||
SiteInsightsTrademarkPlacement,
|
||||
} from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
import { t, getSpaceLanguage } from '@/intl/server';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
|
||||
/**
|
||||
* Trademark link to the GitBook.
|
||||
*/
|
||||
export function Trademark(props: {
|
||||
space: Space;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
placement: SiteInsightsTrademarkPlacement;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -63,8 +71,9 @@ export function Trademark(props: {
|
||||
export function TrademarkLink(props: {
|
||||
space: Space;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
placement: SiteInsightsTrademarkPlacement;
|
||||
}) {
|
||||
const { space, customization } = props;
|
||||
const { space, customization, placement } = props;
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
const url = new URL('https://www.gitbook.com');
|
||||
@@ -73,7 +82,7 @@ export function TrademarkLink(props: {
|
||||
url.searchParams.set('utm_campaign', space.id);
|
||||
|
||||
return (
|
||||
<a
|
||||
<Link
|
||||
target="_blank"
|
||||
href={url.toString()}
|
||||
className={tcls(
|
||||
@@ -104,9 +113,13 @@ export function TrademarkLink(props: {
|
||||
'transition-colors',
|
||||
'pointer-events-auto',
|
||||
)}
|
||||
insights={{
|
||||
type: 'trademark_click',
|
||||
placement,
|
||||
}}
|
||||
>
|
||||
<Icon icon="gitbook" className={tcls('size-5', 'mr-3')} />
|
||||
{t(language, 'powered_by_gitbook')}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import * as api from '@gitbook/api';
|
||||
import NextLink, { LinkProps as NextLinkProps } from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { useTrackEvent, TrackEventInput } from '../Insights';
|
||||
|
||||
// Props from Next, which includes NextLinkProps and all the things anchor elements support.
|
||||
type BaseLinkProps = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof NextLinkProps> &
|
||||
@@ -13,8 +12,11 @@ type BaseLinkProps = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof N
|
||||
} & React.RefAttributes<HTMLAnchorElement>;
|
||||
|
||||
export type LinkInsightsProps = {
|
||||
/** Target of the link, for insights. */
|
||||
insights?: api.SiteInsightsEventLinkClick['link'];
|
||||
/** Event to track when the link is clicked. */
|
||||
insights?:
|
||||
| TrackEventInput<'ad_click'>
|
||||
| TrackEventInput<'link_click'>
|
||||
| TrackEventInput<'trademark_click'>;
|
||||
};
|
||||
|
||||
export type LinkProps = Omit<BaseLinkProps, 'href'> &
|
||||
@@ -40,7 +42,7 @@ export const Link = React.forwardRef(function Link(
|
||||
|
||||
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (insights) {
|
||||
trackEvent({ type: 'link_click', link: insights }, undefined, {
|
||||
trackEvent(insights, undefined, {
|
||||
immediate: isExternal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export const de = {
|
||||
was_this_helpful_neutral: 'Nicht sicher',
|
||||
was_this_helpful_negative: 'Nein',
|
||||
was_this_helpful_thank_you: 'Danke!',
|
||||
was_this_helpful_comment: 'Möchten Sie etwas hinzufügen?',
|
||||
submit: 'Absenden',
|
||||
annotation_button_label: 'Kommentar öffnen',
|
||||
code_copied: 'Kopiert!',
|
||||
code_copy: 'Kopieren',
|
||||
|
||||
@@ -28,6 +28,8 @@ export const en = {
|
||||
was_this_helpful_neutral: 'Not sure',
|
||||
was_this_helpful_negative: 'No',
|
||||
was_this_helpful_thank_you: 'Thank you!',
|
||||
was_this_helpful_comment: "Anything you'd like to add?",
|
||||
submit: 'Submit',
|
||||
annotation_button_label: 'Open annotation',
|
||||
code_copied: 'Copied!',
|
||||
code_copy: 'Copy',
|
||||
|
||||
@@ -30,6 +30,8 @@ export const es: TranslationLanguage = {
|
||||
was_this_helpful_neutral: 'No estoy seguro',
|
||||
was_this_helpful_negative: 'No',
|
||||
was_this_helpful_thank_you: '¡Gracias!',
|
||||
was_this_helpful_comment: '¿Algo más que te gustaría añadir?',
|
||||
submit: 'Enviar',
|
||||
annotation_button_label: 'Abrir anotación',
|
||||
code_copied: '¡Copiado!',
|
||||
code_copy: 'Copiar',
|
||||
|
||||
@@ -29,7 +29,9 @@ export const fr: TranslationLanguage = {
|
||||
was_this_helpful_positive: "Oui, ça l'a été !",
|
||||
was_this_helpful_neutral: 'Pas sûr',
|
||||
was_this_helpful_negative: 'Non',
|
||||
was_this_helpful_thank_you: 'Merci !',
|
||||
was_this_helpful_thank_you: 'Merci!',
|
||||
was_this_helpful_comment: 'Quelque chose à ajouter?',
|
||||
submit: 'Soumettre',
|
||||
annotation_button_label: "Ouvrir l'annotation",
|
||||
code_copied: 'Copié !',
|
||||
code_copy: 'Copier',
|
||||
|
||||
@@ -30,6 +30,8 @@ export const ja: TranslationLanguage = {
|
||||
was_this_helpful_neutral: 'わかりません',
|
||||
was_this_helpful_negative: 'いいえ',
|
||||
was_this_helpful_thank_you: 'ありがとうございます!',
|
||||
was_this_helpful_comment: '何か追加したいことはありますか?',
|
||||
submit: '送信',
|
||||
annotation_button_label: '注釈を開く',
|
||||
code_copied: 'コピーしました!',
|
||||
code_copy: 'コピー',
|
||||
|
||||
@@ -30,6 +30,8 @@ export const nl: TranslationLanguage = {
|
||||
was_this_helpful_neutral: 'Niet zeker',
|
||||
was_this_helpful_negative: 'Nee',
|
||||
was_this_helpful_thank_you: 'Bedankt!',
|
||||
was_this_helpful_comment: 'Nog iets toe te voegen?',
|
||||
submit: 'Versturen',
|
||||
annotation_button_label: 'Open annotatie',
|
||||
code_copied: 'Gekopieerd!',
|
||||
code_copy: 'Kopiëren',
|
||||
|
||||
@@ -30,6 +30,8 @@ export const no: TranslationLanguage = {
|
||||
was_this_helpful_neutral: 'Ikke sikker',
|
||||
was_this_helpful_negative: 'Nei',
|
||||
was_this_helpful_thank_you: 'Takk!',
|
||||
was_this_helpful_comment: 'Noe du vil legge til?',
|
||||
submit: 'Send inn',
|
||||
annotation_button_label: 'Åpne merknad',
|
||||
code_copied: 'Kopiert!',
|
||||
code_copy: 'Kopier',
|
||||
|
||||
@@ -28,6 +28,8 @@ export const pt_br = {
|
||||
was_this_helpful_neutral: 'Não sei',
|
||||
was_this_helpful_negative: 'Não',
|
||||
was_this_helpful_thank_you: 'Obrigado!',
|
||||
was_this_helpful_comment: 'Gostaria de adicionar algo?',
|
||||
submit: 'Enviar',
|
||||
annotation_button_label: 'Abrir anotação',
|
||||
code_copied: 'Copiado!',
|
||||
code_copy: 'Copiar',
|
||||
|
||||
@@ -29,6 +29,8 @@ export const zh: TranslationLanguage = {
|
||||
was_this_helpful_neutral: '不确定',
|
||||
was_this_helpful_negative: '不',
|
||||
was_this_helpful_thank_you: '谢谢!',
|
||||
was_this_helpful_comment: '您有什么想补充的吗?',
|
||||
submit: '提交',
|
||||
annotation_button_label: '打开批注',
|
||||
code_copied: '已复制!',
|
||||
code_copy: '复制',
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { JSONDocument, ContentRef } from '@gitbook/api';
|
||||
|
||||
import { getNodeText } from './document';
|
||||
import { fetchOpenAPIBlock } from './openapi';
|
||||
import { ResolvedContentRef } from './references';
|
||||
|
||||
export interface DocumentSection {
|
||||
id: string;
|
||||
tag?: string;
|
||||
title: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a list of sections from a document.
|
||||
*/
|
||||
export async function getDocumentSections(
|
||||
document: JSONDocument,
|
||||
resolveContentRef: (ref: ContentRef) => Promise<ResolvedContentRef | null>,
|
||||
): Promise<DocumentSection[]> {
|
||||
const sections: DocumentSection[] = [];
|
||||
let depth = 0;
|
||||
|
||||
for (const block of document.nodes) {
|
||||
if ((block.type === 'heading-1' || block.type === 'heading-2') && block.meta?.id) {
|
||||
if (block.type === 'heading-1') {
|
||||
depth = 1;
|
||||
}
|
||||
const title = getNodeText(block);
|
||||
const id = block.meta.id;
|
||||
|
||||
sections.push({
|
||||
id,
|
||||
title,
|
||||
depth: block.type === 'heading-1' ? 1 : depth > 0 ? 2 : 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (block.type === 'swagger' && block.meta?.id) {
|
||||
const { data: operation } = await fetchOpenAPIBlock(block, resolveContentRef);
|
||||
if (operation) {
|
||||
sections.push({
|
||||
id: block.meta.id,
|
||||
tag: operation.method.toUpperCase(),
|
||||
title: operation.operation.summary ?? operation.path,
|
||||
depth: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
import {
|
||||
import type {
|
||||
DocumentText,
|
||||
DocumentInline,
|
||||
DocumentFragment,
|
||||
JSONDocument,
|
||||
DocumentBlock,
|
||||
ContentRef,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import { fetchOpenAPIBlock } from './openapi';
|
||||
import { ResolvedContentRef } from './references';
|
||||
|
||||
export interface DocumentSection {
|
||||
id: string;
|
||||
tag?: string;
|
||||
@@ -34,47 +30,6 @@ export function hasFullWidthBlock(document: JSONDocument): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a list of sections from a document.
|
||||
*/
|
||||
export async function getDocumentSections(
|
||||
document: JSONDocument,
|
||||
resolveContentRef: (ref: ContentRef) => Promise<ResolvedContentRef | null>,
|
||||
): Promise<DocumentSection[]> {
|
||||
const sections: DocumentSection[] = [];
|
||||
let depth = 0;
|
||||
|
||||
for (const block of document.nodes) {
|
||||
if ((block.type === 'heading-1' || block.type === 'heading-2') && block.meta?.id) {
|
||||
if (block.type === 'heading-1') {
|
||||
depth = 1;
|
||||
}
|
||||
const title = getNodeText(block);
|
||||
const id = block.meta.id;
|
||||
|
||||
sections.push({
|
||||
id,
|
||||
title,
|
||||
depth: block.type === 'heading-1' ? 1 : depth > 0 ? 2 : 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (block.type === 'swagger' && block.meta?.id) {
|
||||
const { data: operation } = await fetchOpenAPIBlock(block, resolveContentRef);
|
||||
if (operation) {
|
||||
sections.push({
|
||||
id: block.meta.id,
|
||||
tag: operation.method.toUpperCase(),
|
||||
title: operation.operation.summary ?? operation.path,
|
||||
depth: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the text of a block/inline.
|
||||
*/
|
||||
|
||||
@@ -9,13 +9,14 @@ import { unified } from 'unified';
|
||||
* Parse markdown and output HTML.
|
||||
*/
|
||||
export async function parseMarkdown(markdown: string): Promise<string> {
|
||||
const file = await unified()
|
||||
const promise = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkRehype)
|
||||
.use(rehypeSanitize)
|
||||
.use(rehypeStringify)
|
||||
.process(markdown);
|
||||
.process(markdown)
|
||||
.then((file) => file.toString());
|
||||
|
||||
return file.toString();
|
||||
return promise;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ import {
|
||||
OpenAPIOperationData,
|
||||
fetchOpenAPIOperation,
|
||||
OpenAPIFetcher,
|
||||
parseOpenAPIV3,
|
||||
OpenAPIFetchError,
|
||||
parseOpenAPI,
|
||||
OpenAPIParseError,
|
||||
} from '@gitbook/react-openapi';
|
||||
|
||||
import { cache, parseCacheResponse, noCacheFetchOptions, CacheFunctionOptions } from '@/lib/cache';
|
||||
import { cache, noCacheFetchOptions, CacheFunctionOptions } from '@/lib/cache';
|
||||
|
||||
import { parseMarkdown } from './markdown';
|
||||
import { ResolvedContentRef } from './references';
|
||||
@@ -20,7 +20,7 @@ export async function fetchOpenAPIBlock(
|
||||
resolveContentRef: (ref: ContentRef) => Promise<ResolvedContentRef | null>,
|
||||
): Promise<
|
||||
| { data: OpenAPIOperationData | null; specUrl: string | null; error?: undefined }
|
||||
| { error: OpenAPIFetchError; data?: undefined; specUrl?: undefined }
|
||||
| { error: OpenAPIParseError; data?: undefined; specUrl?: undefined }
|
||||
> {
|
||||
const resolved = block.data.ref ? await resolveContentRef(block.data.ref) : null;
|
||||
if (!resolved || !block.data.path || !block.data.method) {
|
||||
@@ -39,7 +39,7 @@ export async function fetchOpenAPIBlock(
|
||||
|
||||
return { data, specUrl: resolved.href };
|
||||
} catch (error) {
|
||||
if (error instanceof OpenAPIFetchError) {
|
||||
if (error instanceof OpenAPIParseError) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function fetchOpenAPIBlock(
|
||||
|
||||
const fetcher: OpenAPIFetcher = {
|
||||
fetch: cache({
|
||||
name: 'openapi.fetch',
|
||||
name: 'openapi.fetch.v4',
|
||||
get: async (url: string, options: CacheFunctionOptions) => {
|
||||
// Wrap the raw string to prevent invalid URLs from being passed to fetch.
|
||||
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
|
||||
@@ -66,12 +66,14 @@ const fetcher: OpenAPIFetcher = {
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const data = await parseOpenAPIV3(url, text);
|
||||
const data = await parseOpenAPI({ url, value: text, parseMarkdown });
|
||||
return {
|
||||
...parseCacheResponse(response),
|
||||
// Cache for 4 hours
|
||||
ttl: 24 * 60 * 60,
|
||||
// Revalidate every 2 hours
|
||||
revalidateBefore: 22 * 60 * 60,
|
||||
data,
|
||||
};
|
||||
},
|
||||
}),
|
||||
parseMarkdown,
|
||||
};
|
||||
|
||||
@@ -104,6 +104,14 @@ const config: Config = {
|
||||
pomegranate: generateShades('#f25b3a'),
|
||||
periwinkle: generateShades('#acc6ee'),
|
||||
},
|
||||
backgroundColor: {
|
||||
'mark-blue': '#89C6DA4D',
|
||||
'mark-purple': '#DAD4FF4D',
|
||||
'mark-orange': '#FFDCBC4D',
|
||||
'mark-red': '#FFCCCB4D',
|
||||
'mark-yellow': '#FFF0854D',
|
||||
'mark-green': '#91EABF4D',
|
||||
},
|
||||
animation: {
|
||||
present: 'present .5s ease-out both',
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"classnames": "^2.5.1",
|
||||
"@gitbook/api": "^0.85.0",
|
||||
"@gitbook/api": "^0.89.0",
|
||||
"assert-never": "^1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@scalar/api-client-react": "1.0.87",
|
||||
"@scalar/openapi-parser": "^0.10.4",
|
||||
"@scalar/openapi-types": "^0.1.6",
|
||||
"classnames": "^2.5.1",
|
||||
"flatted": "^3.2.9",
|
||||
"openapi-types": "^12.1.3",
|
||||
"swagger2openapi": "^7.0.8",
|
||||
"yaml": "1.10.2",
|
||||
"usehooks-ts": "^3.1.0"
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"yaml": "1.10.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/swagger2openapi": "^7.0.4",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getServersURL } from './OpenAPIServerURL';
|
||||
import { ScalarApiButton } from './ScalarApiButton';
|
||||
import { OpenAPIContextProps } from './types';
|
||||
import { noReference } from './utils';
|
||||
import { stringifyOpenAPI } from './stringifyOpenAPI';
|
||||
|
||||
/**
|
||||
* Display code samples to execute the operation.
|
||||
@@ -32,15 +33,15 @@ export function OpenAPICodeSample(props: {
|
||||
const example = param.schema
|
||||
? generateSchemaExample(noReference(param.schema))
|
||||
: undefined;
|
||||
if (example !== undefined) {
|
||||
if (example !== undefined && param.name) {
|
||||
headersObject[param.name] =
|
||||
typeof example !== 'string' ? JSON.stringify(example) : example;
|
||||
typeof example !== 'string' ? stringifyOpenAPI(example) : example;
|
||||
}
|
||||
} else if (param.in === 'query' && param.required) {
|
||||
const example = param.schema
|
||||
? generateSchemaExample(noReference(param.schema))
|
||||
: undefined;
|
||||
if (example !== undefined) {
|
||||
if (example !== undefined && param.name) {
|
||||
searchParams.append(
|
||||
param.name,
|
||||
String(Array.isArray(example) ? example[0] : example),
|
||||
@@ -50,7 +51,10 @@ export function OpenAPICodeSample(props: {
|
||||
});
|
||||
|
||||
const requestBody = noReference(data.operation.requestBody);
|
||||
const requestBodyContent = requestBody ? Object.entries(requestBody.content)[0] : undefined;
|
||||
const requestBodyContentEntries = requestBody?.content
|
||||
? Object.entries(requestBody.content)
|
||||
: undefined;
|
||||
const requestBodyContent = requestBodyContentEntries?.[0];
|
||||
|
||||
const input: CodeSampleInput = {
|
||||
url:
|
||||
@@ -119,7 +123,11 @@ export function OpenAPICodeSample(props: {
|
||||
tabs={samples}
|
||||
overlay={
|
||||
data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'] ? null : (
|
||||
<ScalarApiButton method={data.method} path={data.path} />
|
||||
<ScalarApiButton
|
||||
method={data.method}
|
||||
path={data.path}
|
||||
specUrl={context.specUrl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { ApiClientModalProvider } from '@scalar/api-client-react';
|
||||
|
||||
import { OpenAPIOperationData, toJSON } from './fetchOpenAPIOperation';
|
||||
import { OpenAPIOperationData } from './fetchOpenAPIOperation';
|
||||
import { Markdown } from './Markdown';
|
||||
import { OpenAPICodeSample } from './OpenAPICodeSample';
|
||||
import { OpenAPIResponseExample } from './OpenAPIResponseExample';
|
||||
@@ -28,45 +27,40 @@ export function OpenAPIOperation(props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<ApiClientModalProvider
|
||||
configuration={{ spec: { url: context.specUrl } }}
|
||||
initialRequest={{ path: data.path, method: data.method }}
|
||||
>
|
||||
<div className={classNames('openapi-operation', className)}>
|
||||
<div className="openapi-intro">
|
||||
<h2 className="openapi-summary" id={context.id}>
|
||||
{operation.summary}
|
||||
</h2>
|
||||
{operation.description ? (
|
||||
<Markdown className="openapi-description" source={operation.description} />
|
||||
) : null}
|
||||
<div className="openapi-target">
|
||||
<span
|
||||
className={classNames(
|
||||
'openapi-method',
|
||||
`openapi-method-${method.toLowerCase()}`,
|
||||
)}
|
||||
>
|
||||
{method.toUpperCase()}
|
||||
</span>
|
||||
<span className="openapi-url">
|
||||
<OpenAPIServerURL servers={servers} />
|
||||
{path}
|
||||
</span>
|
||||
</div>
|
||||
<div className={classNames('openapi-operation', className)}>
|
||||
<div className="openapi-intro">
|
||||
<h2 className="openapi-summary" id={context.id}>
|
||||
{operation.summary}
|
||||
</h2>
|
||||
{operation.description ? (
|
||||
<Markdown className="openapi-description" source={operation.description} />
|
||||
) : null}
|
||||
<div className="openapi-target">
|
||||
<span
|
||||
className={classNames(
|
||||
'openapi-method',
|
||||
`openapi-method-${method.toLowerCase()}`,
|
||||
)}
|
||||
>
|
||||
{method.toUpperCase()}
|
||||
</span>
|
||||
<span className="openapi-url">
|
||||
<OpenAPIServerURL servers={servers} />
|
||||
{path}
|
||||
</span>
|
||||
</div>
|
||||
<div className={classNames('openapi-columns')}>
|
||||
<div className={classNames('openapi-column-spec')}>
|
||||
<OpenAPISpec rawData={toJSON(data)} context={clientContext} />
|
||||
</div>
|
||||
<div className={classNames('openapi-column-preview')}>
|
||||
<div className={classNames('openapi-column-preview-body')}>
|
||||
<OpenAPICodeSample {...props} />
|
||||
<OpenAPIResponseExample {...props} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames('openapi-columns')}>
|
||||
<div className={classNames('openapi-column-spec')}>
|
||||
<OpenAPISpec data={data} context={clientContext} />
|
||||
</div>
|
||||
<div className={classNames('openapi-column-preview')}>
|
||||
<div className={classNames('openapi-column-preview-body')}>
|
||||
<OpenAPICodeSample {...props} />
|
||||
<OpenAPIResponseExample {...props} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ApiClientModalProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { OpenAPIV3 } from '@scalar/openapi-types';
|
||||
import { OpenAPIRootSchema } from './OpenAPISchema';
|
||||
import { noReference } from './utils';
|
||||
import { OpenAPIClientContext } from './types';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { OpenAPIV3 } from '@scalar/openapi-types';
|
||||
import { OpenAPIRootSchema, OpenAPISchemaProperties } from './OpenAPISchema';
|
||||
import { noReference } from './utils';
|
||||
import { OpenAPIClientContext } from './types';
|
||||
|
||||
@@ -4,6 +4,7 @@ import { OpenAPIOperationData } from './fetchOpenAPIOperation';
|
||||
import { generateSchemaExample } from './generateSchemaExample';
|
||||
import { OpenAPIContextProps } from './types';
|
||||
import { createStateKey, noReference } from './utils';
|
||||
import { stringifyOpenAPI } from './stringifyOpenAPI';
|
||||
|
||||
/**
|
||||
* Display an example of the response content.
|
||||
@@ -41,6 +42,11 @@ export function OpenAPIResponseExample(props: {
|
||||
.map((response) => {
|
||||
const responseObject = noReference(response[1]);
|
||||
|
||||
// TODO: unnecessary with https://github.com/GitbookIO/gitbook/pull/2780
|
||||
if (!responseObject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const schema = noReference(
|
||||
(
|
||||
responseObject.content?.['application/json'] ??
|
||||
@@ -63,7 +69,9 @@ export function OpenAPIResponseExample(props: {
|
||||
body: (
|
||||
<context.CodeBlock
|
||||
code={
|
||||
typeof example === 'string' ? example : JSON.stringify(example, null, 2)
|
||||
typeof example === 'string'
|
||||
? example
|
||||
: stringifyOpenAPI(example, null, 2)
|
||||
}
|
||||
syntax="json"
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { createStateKey, noReference } from './utils';
|
||||
import { OpenAPIResponse } from './OpenAPIResponse';
|
||||
import { OpenAPIClientContext } from './types';
|
||||
import { InteractiveSection } from './InteractiveSection';
|
||||
import { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
|
||||
|
||||
/**
|
||||
* Display an interactive response body.
|
||||
*/
|
||||
export function OpenAPIResponses(props: {
|
||||
responses: OpenAPIV3.ResponsesObject;
|
||||
responses: OpenAPIV3.ResponsesObject | OpenAPIV3_1.ResponsesObject;
|
||||
context: OpenAPIClientContext;
|
||||
}) {
|
||||
const { responses, context } = props;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { it, describe, expect } from 'bun:test';
|
||||
import { getSchemaAlternatives } from './OpenAPISchema';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { OpenAPIV3 } from '@scalar/openapi-types';
|
||||
|
||||
describe('getSchemaAlternatives', () => {
|
||||
it('should flatten oneOf', () => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import classNames from 'classnames';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { OpenAPIV3 } from '@scalar/openapi-types';
|
||||
import React, { useId } from 'react';
|
||||
|
||||
import { InteractiveSection } from './InteractiveSection';
|
||||
import { Markdown } from './Markdown';
|
||||
import { SYMBOL_REF_RESOLVED } from './resolveOpenAPIPath';
|
||||
import { OpenAPIClientContext } from './types';
|
||||
import { noReference } from './utils';
|
||||
import { stringifyOpenAPI } from './stringifyOpenAPI';
|
||||
|
||||
type CircularRefsIds = Map<OpenAPIV3.SchemaObject, string>;
|
||||
|
||||
@@ -101,7 +101,7 @@ export function OpenAPISchemaProperty(
|
||||
) : null}
|
||||
{shouldDisplayExample(schema) ? (
|
||||
<span className="openapi-schema-example">
|
||||
Example: <code>{JSON.stringify(schema.example)}</code>
|
||||
Example: <code>{stringifyOpenAPI(schema.example)}</code>
|
||||
</span>
|
||||
) : null}
|
||||
{schema.pattern ? (
|
||||
@@ -361,7 +361,7 @@ function getSchemaTitle(
|
||||
}
|
||||
|
||||
// Try using the discriminator
|
||||
if (discriminator && schema.properties) {
|
||||
if (discriminator?.propertyName && schema.properties) {
|
||||
const discriminatorProperty = noReference(schema.properties[discriminator.propertyName]);
|
||||
if (discriminatorProperty) {
|
||||
if (discriminatorProperty.enum) {
|
||||
@@ -394,10 +394,6 @@ function getSchemaTitle(
|
||||
type = 'not';
|
||||
}
|
||||
|
||||
if (SYMBOL_REF_RESOLVED in schema) {
|
||||
type = `${schema[SYMBOL_REF_RESOLVED]} (${type})`;
|
||||
}
|
||||
|
||||
if (schema.nullable) {
|
||||
type = `nullable ${type}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { OpenAPIV3_1 } from '@scalar/openapi-types';
|
||||
import { OpenAPIClientContext } from './types';
|
||||
import { InteractiveSection } from './InteractiveSection';
|
||||
import { Markdown } from './Markdown';
|
||||
@@ -47,7 +47,7 @@ export function OpenAPISecurities(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function getLabelForType(security: OpenAPIV3.SecuritySchemeObject) {
|
||||
function getLabelForType(security: OpenAPIV3_1.SecuritySchemeObject): string {
|
||||
switch (security.type) {
|
||||
case 'apiKey':
|
||||
return 'API Key';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { OpenAPIV3 } from '@scalar/openapi-types';
|
||||
import { OpenAPIServerURLVariable } from './OpenAPIServerURLVariable';
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { OpenAPIV3 } from '@scalar/openapi-types';
|
||||
|
||||
/**
|
||||
* Interactive component to show the value of a server variable and let the user change it.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { OpenAPIV3, OpenAPIV2, OpenAPI } from '@scalar/openapi-types';
|
||||
|
||||
import { OpenAPIOperationData, fromJSON } from './fetchOpenAPIOperation';
|
||||
import { InteractiveSection } from './InteractiveSection';
|
||||
@@ -18,13 +18,13 @@ import { noReference } from './utils';
|
||||
* We use a client component as rendering recursive JSON schema in the server is expensive
|
||||
* (the entire schema is rendered at once, while the client component only renders the visible part)
|
||||
*/
|
||||
export function OpenAPISpec(props: { rawData: any; context: OpenAPIClientContext }) {
|
||||
const { rawData, context } = props;
|
||||
export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAPIClientContext }) {
|
||||
const { data, context } = props;
|
||||
|
||||
const parsedData = fromJSON(rawData) as OpenAPIOperationData;
|
||||
const { operation, securities } = parsedData;
|
||||
const { operation, securities } = data;
|
||||
|
||||
const parameterGroups = groupParameters((operation.parameters || []).map(noReference));
|
||||
const parameters = operation.parameters ?? [];
|
||||
const parameterGroups = groupParameters(parameters);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -72,17 +72,17 @@ export function OpenAPISpec(props: { rawData: any; context: OpenAPIClientContext
|
||||
);
|
||||
}
|
||||
|
||||
function groupParameters(parameters: OpenAPIV3.ParameterObject[]): Array<{
|
||||
function groupParameters(parameters: OpenAPI.Parameters): Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
parameters: OpenAPIV3.ParameterObject[];
|
||||
parameters: OpenAPI.Parameters;
|
||||
}> {
|
||||
const sorted = ['path', 'query', 'header'];
|
||||
|
||||
const groups: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
parameters: OpenAPIV3.ParameterObject[];
|
||||
parameters: OpenAPI.Parameters;
|
||||
}> = [];
|
||||
|
||||
parameters.forEach((parameter) => {
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useApiClientModal } from '@scalar/api-client-react';
|
||||
import React from 'react';
|
||||
import { ApiClientModalProvider, useApiClientModal } from '@scalar/api-client-react';
|
||||
import React, { useImperativeHandle, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useOpenAPIOperationContext } from './OpenAPIOperationContext';
|
||||
import { useEventCallback } from 'usehooks-ts';
|
||||
|
||||
/**
|
||||
* Button which launches the Scalar API Client
|
||||
*/
|
||||
export function ScalarApiButton({ method, path }: { method: string; path: string }) {
|
||||
const client = useApiClientModal();
|
||||
const { onOpenClient } = useOpenAPIOperationContext();
|
||||
export function ScalarApiButton({
|
||||
method,
|
||||
path,
|
||||
specUrl,
|
||||
}: {
|
||||
method: string;
|
||||
path: string;
|
||||
specUrl: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const controllerRef = useRef<ScalarModalControllerRef>(null);
|
||||
return (
|
||||
<div className="scalar scalar-activate">
|
||||
<button
|
||||
className="scalar-activate-button"
|
||||
onClick={() => {
|
||||
client?.open({ method, path });
|
||||
onOpenClient({ method, path });
|
||||
controllerRef.current?.openClient?.();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="12" fill="none">
|
||||
@@ -29,6 +39,68 @@ export function ScalarApiButton({ method, path }: { method: string; path: string
|
||||
</svg>
|
||||
Test it
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<ScalarModal
|
||||
controllerRef={controllerRef}
|
||||
method={method}
|
||||
path={path}
|
||||
specUrl={specUrl}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScalarModal(props: {
|
||||
method: string;
|
||||
path: string;
|
||||
specUrl: string;
|
||||
controllerRef: React.Ref<ScalarModalControllerRef>;
|
||||
}) {
|
||||
return (
|
||||
<ApiClientModalProvider
|
||||
configuration={{ spec: { url: props.specUrl } }}
|
||||
initialRequest={{ path: props.path, method: props.method }}
|
||||
>
|
||||
<ScalarModalController
|
||||
method={props.method}
|
||||
path={props.path}
|
||||
controllerRef={props.controllerRef}
|
||||
/>
|
||||
</ApiClientModalProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type ScalarModalControllerRef = {
|
||||
openClient: (() => void) | undefined;
|
||||
};
|
||||
|
||||
function ScalarModalController(props: {
|
||||
method: string;
|
||||
path: string;
|
||||
controllerRef: React.Ref<ScalarModalControllerRef>;
|
||||
}) {
|
||||
const client = useApiClientModal();
|
||||
const openClient = client?.open;
|
||||
useImperativeHandle(
|
||||
props.controllerRef,
|
||||
() => ({ openClient: openClient ? () => openClient() : undefined }),
|
||||
[openClient],
|
||||
);
|
||||
|
||||
// Open the client when the component is mounted.
|
||||
const { onOpenClient } = useOpenAPIOperationContext();
|
||||
const trackOpening = useEventCallback(() => {
|
||||
onOpenClient({ method: props.method, path: props.path });
|
||||
});
|
||||
React.useEffect(() => {
|
||||
if (openClient) {
|
||||
openClient();
|
||||
trackOpening();
|
||||
}
|
||||
}, [openClient]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { stringifyOpenAPI } from './stringifyOpenAPI';
|
||||
|
||||
export interface CodeSampleInput {
|
||||
method: string;
|
||||
url: string;
|
||||
@@ -24,11 +26,11 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [
|
||||
method: '${method.toUpperCase()}',\n`;
|
||||
|
||||
if (headers) {
|
||||
code += indent(`headers: ${JSON.stringify(headers, null, 2)},\n`, 4);
|
||||
code += indent(`headers: ${stringifyOpenAPI(headers, null, 2)},\n`, 4);
|
||||
}
|
||||
|
||||
if (body) {
|
||||
code += indent(`body: JSON.stringify(${JSON.stringify(body, null, 2)}),\n`, 4);
|
||||
code += indent(`body: JSON.stringify(${stringifyOpenAPI(body, null, 2)}),\n`, 4);
|
||||
}
|
||||
|
||||
code += `});\n`;
|
||||
@@ -59,7 +61,7 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [
|
||||
lines.push(`'${url}'`);
|
||||
|
||||
if (body) {
|
||||
lines.push(`-d '${JSON.stringify(body)}'`);
|
||||
lines.push(`-d '${stringifyOpenAPI(body)}'`);
|
||||
}
|
||||
|
||||
return lines.map((line, index) => (index > 0 ? indent(line, 2) : line)).join(separator);
|
||||
@@ -74,10 +76,10 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [
|
||||
code += `response = requests.${method.toLowerCase()}(\n`;
|
||||
code += indent(`"${url}",\n`, 4);
|
||||
if (headers) {
|
||||
code += indent(`headers=${JSON.stringify(headers)},\n`, 4);
|
||||
code += indent(`headers=${stringifyOpenAPI(headers)},\n`, 4);
|
||||
}
|
||||
if (body) {
|
||||
code += indent(`json=${JSON.stringify(body)}\n`, 4);
|
||||
code += indent(`json=${stringifyOpenAPI(body)}\n`, 4);
|
||||
}
|
||||
code += ')\n';
|
||||
code += `data = response.json()`;
|
||||
@@ -93,7 +95,7 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [
|
||||
|
||||
if (body) {
|
||||
// if we had a body add a content length header
|
||||
const bodyContent = body ? JSON.stringify(body) : '';
|
||||
const bodyContent = body ? stringifyOpenAPI(body) : '';
|
||||
// handle unicode chars with a text encoder
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
@@ -115,7 +117,7 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [
|
||||
.join('\n') + '\n'
|
||||
: '';
|
||||
|
||||
const bodyString = body ? `\n${JSON.stringify(body, null, 2)}` : '';
|
||||
const bodyString = body ? `\n${stringifyOpenAPI(body, null, 2)}` : '';
|
||||
|
||||
const httpRequest = `${method.toUpperCase()} ${decodeURI(path)} HTTP/1.1
|
||||
Host: ${host}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { it, expect } from 'bun:test';
|
||||
|
||||
import { fetchOpenAPIOperation, parseOpenAPIV3 } from './fetchOpenAPIOperation';
|
||||
import { OpenAPIFetcher } from './types';
|
||||
import { fetchOpenAPIOperation, OpenAPIFetcher } from './fetchOpenAPIOperation';
|
||||
import { parseOpenAPI } from './parser';
|
||||
|
||||
const fetcher: OpenAPIFetcher = {
|
||||
fetch: async (url) => {
|
||||
const response = await fetch(url);
|
||||
return parseOpenAPIV3(url, await response.text());
|
||||
return parseOpenAPI({ value: await response.text(), url, parseMarkdown: async (v) => v });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { toJSON, fromJSON } from 'flatted';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import YAML from 'yaml';
|
||||
import swagger2openapi, { ConvertOutputOptions } from 'swagger2openapi';
|
||||
|
||||
import { resolveOpenAPIPath } from './resolveOpenAPIPath';
|
||||
import { OpenAPIFetcher } from './types';
|
||||
import { OpenAPICustomSpecProperties, OpenAPIParseError } from './parser';
|
||||
import { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
|
||||
import { noReference } from './utils';
|
||||
import { dereference } from '@scalar/openapi-parser';
|
||||
|
||||
export interface OpenAPIFetcher {
|
||||
/**
|
||||
* Fetch an OpenAPI file by its URL. It should return a fully parsed OpenAPI v3 document.
|
||||
*/
|
||||
fetch: (
|
||||
url: string,
|
||||
) => Promise<
|
||||
| OpenAPIV3_1.Document<OpenAPICustomSpecProperties>
|
||||
| OpenAPIV3.Document<OpenAPICustomSpecProperties>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
|
||||
path: string;
|
||||
@@ -14,56 +25,12 @@ export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
|
||||
servers: OpenAPIV3.ServerObject[];
|
||||
|
||||
/** Spec of the operation */
|
||||
operation: OpenAPIV3.OperationObject & OpenAPICustomOperationProperties;
|
||||
operation: OpenAPIV3.OperationObject;
|
||||
|
||||
/** Securities that should be used for this operation */
|
||||
securities: [string, OpenAPIV3.SecuritySchemeObject][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom properties that can be defined at the entire spec level.
|
||||
*/
|
||||
export interface OpenAPICustomSpecProperties {
|
||||
/**
|
||||
* If `true`, code samples will not be displayed.
|
||||
* This option can be used to hide code samples for the entire spec.
|
||||
*/
|
||||
'x-codeSamples'?: boolean;
|
||||
|
||||
/**
|
||||
* If `true`, the "Try it" button will not be displayed.
|
||||
* This option can be used to hide code samples for the entire spec.
|
||||
*/
|
||||
'x-hideTryItPanel'?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom properties that can be defined at the operation level.
|
||||
* These properties are not part of the OpenAPI spec.
|
||||
*/
|
||||
export interface OpenAPICustomOperationProperties {
|
||||
'x-code-samples'?: OpenAPICustomCodeSample[];
|
||||
'x-codeSamples'?: OpenAPICustomCodeSample[] | false;
|
||||
'x-custom-examples'?: OpenAPICustomCodeSample[];
|
||||
|
||||
/**
|
||||
* If `true`, the "Try it" button will not be displayed.
|
||||
* https://redocly.com/docs/api-reference-docs/specification-extensions/x-hidetryitpanel/
|
||||
*/
|
||||
'x-hideTryItPanel'?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom code samples that can be defined at the operation level.
|
||||
* It follows the spec defined by Redocly.
|
||||
* https://redocly.com/docs/api-reference-docs/specification-extensions/x-code-samples/
|
||||
*/
|
||||
export interface OpenAPICustomCodeSample {
|
||||
lang: string;
|
||||
label: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export { toJSON, fromJSON };
|
||||
|
||||
/**
|
||||
@@ -75,28 +42,19 @@ export async function fetchOpenAPIOperation(
|
||||
path: string;
|
||||
method: string;
|
||||
},
|
||||
rawFetcher: OpenAPIFetcher,
|
||||
fetcher: OpenAPIFetcher,
|
||||
): Promise<OpenAPIOperationData | null> {
|
||||
const fetcher = cacheFetcher(rawFetcher);
|
||||
const refSchema = await fetcher.fetch(input.url);
|
||||
const schema = await memoDereferenceSchema(refSchema, input.url);
|
||||
|
||||
let operation = await resolveOpenAPIPath<OpenAPIV3.OperationObject>(
|
||||
input.url,
|
||||
['paths', input.path, input.method],
|
||||
fetcher,
|
||||
);
|
||||
let operation = getOperationByPathAndMethod(schema, input.path, input.method);
|
||||
|
||||
if (!operation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const specData = await fetcher.fetch(input.url);
|
||||
|
||||
// Resolve common parameters
|
||||
const commonParameters = await resolveOpenAPIPath<OpenAPIV3.ParameterObject[]>(
|
||||
input.url,
|
||||
['paths', input.path, 'parameters'],
|
||||
fetcher,
|
||||
);
|
||||
const commonParameters = getPathObjectParameter(schema, input.path);
|
||||
if (commonParameters) {
|
||||
operation = {
|
||||
...operation,
|
||||
@@ -104,127 +62,111 @@ export async function fetchOpenAPIOperation(
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve servers
|
||||
const servers = await resolveOpenAPIPath<OpenAPIV3.ServerObject[]>(
|
||||
input.url,
|
||||
['servers'],
|
||||
fetcher,
|
||||
);
|
||||
const servers = 'servers' in schema ? (schema.servers ?? []) : [];
|
||||
const security = operation.security ?? schema.security ?? [];
|
||||
|
||||
// Resolve securities
|
||||
const securities: OpenAPIOperationData['securities'] = [];
|
||||
for (const security of operation.security ?? []) {
|
||||
const securityKey = Object.keys(security)[0];
|
||||
|
||||
const securityScheme = await resolveOpenAPIPath<OpenAPIV3.SecuritySchemeObject>(
|
||||
input.url,
|
||||
['components', 'securitySchemes', securityKey],
|
||||
fetcher,
|
||||
);
|
||||
|
||||
for (const entry of security) {
|
||||
const securityKey = Object.keys(entry)[0];
|
||||
const securityScheme = schema.components?.securitySchemes?.[securityKey];
|
||||
if (securityScheme) {
|
||||
securities.push([securityKey, securityScheme]);
|
||||
securities.push([securityKey, noReference(securityScheme)]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
servers: servers ?? [],
|
||||
servers,
|
||||
operation,
|
||||
method: input.method,
|
||||
path: input.path,
|
||||
securities,
|
||||
'x-codeSamples':
|
||||
typeof specData['x-codeSamples'] === 'boolean' ? specData['x-codeSamples'] : undefined,
|
||||
typeof schema['x-codeSamples'] === 'boolean' ? schema['x-codeSamples'] : undefined,
|
||||
'x-hideTryItPanel':
|
||||
typeof specData['x-hideTryItPanel'] === 'boolean'
|
||||
? specData['x-hideTryItPanel']
|
||||
typeof schema['x-hideTryItPanel'] === 'boolean'
|
||||
? schema['x-hideTryItPanel']
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function cacheFetcher(fetcher: OpenAPIFetcher): OpenAPIFetcher {
|
||||
const cache = new Map<string, Promise<any>>();
|
||||
const dereferenceSchemaCache = new WeakMap<OpenAPI.Document, Promise<OpenAPI.Document>>();
|
||||
|
||||
return {
|
||||
async fetch(url) {
|
||||
if (cache.has(url)) {
|
||||
return cache.get(url);
|
||||
}
|
||||
/**
|
||||
* Memoized version of `dereferenceSchema`.
|
||||
*/
|
||||
function memoDereferenceSchema<T extends OpenAPI.Document>(schema: T, url: string): Promise<T> {
|
||||
if (dereferenceSchemaCache.has(schema)) {
|
||||
return dereferenceSchemaCache.get(schema) as Promise<T>;
|
||||
}
|
||||
|
||||
const promise = fetcher.fetch(url);
|
||||
cache.set(url, promise);
|
||||
return promise;
|
||||
},
|
||||
parseMarkdown: fetcher.parseMarkdown,
|
||||
};
|
||||
const promise = dereferenceSchema(schema, url);
|
||||
dereferenceSchemaCache.set(schema, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw string into an OpenAPI document.
|
||||
* It will also convert Swagger 2.0 to OpenAPI 3.0.
|
||||
* It can throw an `OpenAPIFetchError` if the document is invalid.
|
||||
* Dereference an OpenAPI schema.
|
||||
*/
|
||||
export async function parseOpenAPIV3(url: string, text: string): Promise<OpenAPIV3.Document> {
|
||||
// Parse the JSON or YAML
|
||||
let data: unknown;
|
||||
async function dereferenceSchema<T extends OpenAPI.Document>(schema: T, url: string): Promise<T> {
|
||||
const derefResult = await dereference(schema);
|
||||
|
||||
// Try with JSON
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (jsonError) {
|
||||
try {
|
||||
// Try with YAML
|
||||
data = YAML.parse(text);
|
||||
} catch (yamlError) {
|
||||
if (yamlError instanceof Error && yamlError.name.startsWith('YAML')) {
|
||||
throw new OpenAPIFetchError('Failed to parse YAML: ' + yamlError.message, url);
|
||||
} else {
|
||||
throw yamlError;
|
||||
}
|
||||
}
|
||||
if (!derefResult.schema) {
|
||||
throw new OpenAPIParseError(
|
||||
'Failed to dereference OpenAPI document',
|
||||
url,
|
||||
'failed-dereference',
|
||||
);
|
||||
}
|
||||
|
||||
// Convert Swagger 2.0 to OpenAPI 3.0
|
||||
// @ts-ignore
|
||||
if (data && data.swagger) {
|
||||
try {
|
||||
// Convert Swagger 2.0 to OpenAPI 3.0
|
||||
// @ts-ignore
|
||||
const result = (await swagger2openapi.convertObj(data, {
|
||||
resolve: false,
|
||||
resolveInternal: false,
|
||||
laxDefaults: true,
|
||||
laxurls: true,
|
||||
lint: false,
|
||||
prevalidate: false,
|
||||
anchors: true,
|
||||
patch: true,
|
||||
})) as ConvertOutputOptions;
|
||||
|
||||
data = result.openapi;
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'S2OError') {
|
||||
throw new OpenAPIFetchError(
|
||||
'Failed to convert Swagger 2.0 to OpenAPI 3.0: ' + (error as Error).message,
|
||||
url,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return data;
|
||||
return derefResult.schema as T;
|
||||
}
|
||||
|
||||
export class OpenAPIFetchError extends Error {
|
||||
public name = 'OpenAPIFetchError';
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly url: string,
|
||||
) {
|
||||
super(message);
|
||||
/**
|
||||
* Get a path object from its path.
|
||||
*/
|
||||
function getPathObject(
|
||||
schema: OpenAPIV3.Document | OpenAPIV3_1.Document,
|
||||
path: string,
|
||||
): OpenAPIV3.PathItemObject | OpenAPIV3_1.PathItemObject | null {
|
||||
if (schema.paths?.[path]) {
|
||||
return schema.paths[path];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve parameters from a path in an OpenAPI schema.
|
||||
*/
|
||||
function getPathObjectParameter(
|
||||
schema: OpenAPIV3.Document | OpenAPIV3_1.Document,
|
||||
path: string,
|
||||
): OpenAPIV3.ParameterObject[] | OpenAPIV3_1.ParameterObject[] | null {
|
||||
const pathObject = getPathObject(schema, path);
|
||||
if (pathObject?.parameters) {
|
||||
return pathObject.parameters.map(noReference) as
|
||||
| OpenAPIV3.ParameterObject[]
|
||||
| OpenAPIV3_1.ParameterObject[];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an operation by its path and method.
|
||||
*/
|
||||
function getOperationByPathAndMethod(
|
||||
schema: OpenAPIV3.Document | OpenAPIV3_1.Document,
|
||||
path: string,
|
||||
method: string,
|
||||
): OpenAPIV3.OperationObject | null {
|
||||
// Types are buffy for OpenAPIV3_1.OperationObject, so we use v3
|
||||
const pathObject = getPathObject(schema, path);
|
||||
if (!pathObject) {
|
||||
return null;
|
||||
}
|
||||
const normalizedMethod = method.toLowerCase();
|
||||
if (!pathObject[normalizedMethod]) {
|
||||
return null;
|
||||
}
|
||||
return pathObject[normalizedMethod];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { OpenAPIV3 } from '@scalar/openapi-types';
|
||||
import { noReference } from './utils';
|
||||
|
||||
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './fetchOpenAPIOperation';
|
||||
export * from './OpenAPIOperation';
|
||||
export type { OpenAPIFetcher } from './types';
|
||||
export * from './OpenAPIOperationContext';
|
||||
export * from './parser';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export class OpenAPIParseError extends Error {
|
||||
public name = 'OpenAPIParseError';
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly url: string,
|
||||
public readonly code?: 'invalid-spec' | 'v2-spec' | 'failed-dereference',
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user