mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-27 12:39:08 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e11b009f6 | |||
| ce9dc0560e | |||
| 6f8a80712b | |||
| dda46c1086 | |||
| 0acc1c497b | |||
| bd9a3671e5 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix OpenAPI tabs indicator overflow
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Fix XML in code sample
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix favicon not being displayed in Google because `robots.txt` was preventing the indexation of the image route
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix alignment of `prominent` search bar on full-width pages
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Disallow crawling by web-robots of search/ask URLs
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix multiple request examples selector not showing
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/openapi-parser": patch
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Update scalar
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Restyle section group dropdown
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix a crash when a page contains a block of an integration that is no longer installed
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix spec properties rendering and missing keys
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Trim the search query to avoid showing a loading state when typing
|
||||
@@ -50,7 +50,6 @@ runs:
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||
- name: Build worker
|
||||
run: bun run turbo build:v2:cloudflare
|
||||
shell: bash
|
||||
|
||||
@@ -55,7 +55,6 @@ runs:
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||
- name: Build Project Artifacts
|
||||
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
|
||||
shell: bash
|
||||
@@ -75,4 +74,3 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
|
||||
|
||||
@@ -3,6 +3,15 @@ description: 'Install Bun and cache dependencies'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
# We cache dependencies even if Bun is fast to avoid downloading fontawesome too many times
|
||||
# and impacting bandwidth there.
|
||||
- name: Cache bun
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
|
||||
@@ -5,9 +5,6 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
|
||||
Vendored
-3
@@ -13,8 +13,5 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -7,12 +7,12 @@
|
||||
"turbo": "^2.4.4",
|
||||
"vercel": "^39.3.0"
|
||||
},
|
||||
"packageManager": "bun@1.2.5",
|
||||
"packageManager": "bun@1.2.4",
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"@gitbook/api": "0.102.0"
|
||||
"@gitbook/api": "0.99.0"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# gitbook-v2
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 54ee014: Add initial support for loading custom fonts
|
||||
- bba2e52: Fix site redirects when it includes a section/variant path
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -5,17 +5,7 @@
|
||||
*/
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
// This is needed to throw "forbidden" when the api token expired during revalidation
|
||||
authInterrupts: true,
|
||||
|
||||
// This is needed to use 'use cache'
|
||||
useCache: true,
|
||||
|
||||
// Content is fully static, we can cache it in the session memory cache for a long time
|
||||
staleTimes: {
|
||||
dynamic: 3600, // 1 hour
|
||||
static: 3600, // 1 hour
|
||||
},
|
||||
},
|
||||
|
||||
env: {
|
||||
@@ -33,7 +23,6 @@ const nextConfig = {
|
||||
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
||||
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
|
||||
|
||||
// Next.js envs
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
{
|
||||
"name": "gitbook-v2",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"next": "^15.2.3",
|
||||
"next": "^15.2.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@gitbook/api": "*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"server-only": "^0.0.1",
|
||||
"warn-once": "^0.1.1",
|
||||
"rison": "^0.1.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"p-memoize": "^7.1.1"
|
||||
"warn-once": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"gitbook": "*",
|
||||
"@opennextjs/cloudflare": "^0.5.10",
|
||||
"@types/rison": "^0.0.9",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8"
|
||||
},
|
||||
|
||||
+3
-3
@@ -13,20 +13,20 @@ type PageProps = {
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const context = await getDynamicSiteContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
return <SitePage context={context} pageParams={{ pathname }} />;
|
||||
}
|
||||
|
||||
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
||||
const { context } = await getDynamicSiteContext(await props.params);
|
||||
const context = await getDynamicSiteContext(await props.params);
|
||||
return generateSitePageViewport(context);
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const context = await getDynamicSiteContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
return generateSitePageMetadata({
|
||||
+6
-5
@@ -6,7 +6,7 @@ import {
|
||||
} from '@/components/SiteLayout';
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
|
||||
import { getThemeFromMiddleware } from '@v2/lib/middleware';
|
||||
import { getThemeFromMiddleware, getVisitorAuthTokenFromMiddleware } from '@v2/lib/middleware';
|
||||
|
||||
interface SiteDynamicLayoutProps {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
@@ -16,8 +16,9 @@ export default async function SiteDynamicLayout({
|
||||
params,
|
||||
children,
|
||||
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
|
||||
const { context, visitorAuthClaims } = await getDynamicSiteContext(await params);
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const forcedTheme = await getThemeFromMiddleware();
|
||||
const visitorAuthToken = await getVisitorAuthTokenFromMiddleware();
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout customization={context.customization}>
|
||||
@@ -25,7 +26,7 @@ export default async function SiteDynamicLayout({
|
||||
context={context}
|
||||
forcedTheme={forcedTheme}
|
||||
withTracking={!GITBOOK_DISABLE_TRACKING}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
visitorAuthToken={visitorAuthToken}
|
||||
>
|
||||
{children}
|
||||
</SiteLayout>
|
||||
@@ -34,11 +35,11 @@ export default async function SiteDynamicLayout({
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: SiteDynamicLayoutProps) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
return generateSiteLayoutViewport(context);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SiteDynamicLayoutProps) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
return generateSiteLayoutMetadata(context);
|
||||
}
|
||||
+1
-1
@@ -7,6 +7,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
return serveLLMsTxt(context);
|
||||
}
|
||||
+1
-1
@@ -7,6 +7,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
return serveRobotsTxt(context);
|
||||
}
|
||||
+1
-1
@@ -7,6 +7,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
return servePagesSitemap(context);
|
||||
}
|
||||
+1
-1
@@ -7,6 +7,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
return serveRootSitemap(context);
|
||||
}
|
||||
+1
-1
@@ -7,6 +7,6 @@ export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
return serveIcon(context, request);
|
||||
}
|
||||
+1
-1
@@ -8,6 +8,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
|
||||
) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
return serveOGImage(context, await params);
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ export default async function RootLayout(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { params, children } = props;
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
|
||||
return <PDFRootLayout context={context}>{children}</PDFRootLayout>;
|
||||
}
|
||||
+2
-2
@@ -6,7 +6,7 @@ export async function generateMetadata({
|
||||
}: {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
}) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
return generatePDFMetadata(context);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,6 @@ export default async function Page(props: {
|
||||
searchParams: Promise<{ [key: string]: string }>;
|
||||
}) {
|
||||
const { params, searchParams } = props;
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
return <PDFPage context={context} searchParams={await searchParams} />;
|
||||
}
|
||||
+3
-3
@@ -18,7 +18,7 @@ export default async function Page(props: PageProps) {
|
||||
'use cache';
|
||||
|
||||
const params = await props.params;
|
||||
const { context } = await getStaticSiteContext(params);
|
||||
const context = await getStaticSiteContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
cacheTag(
|
||||
@@ -32,13 +32,13 @@ export default async function Page(props: PageProps) {
|
||||
}
|
||||
|
||||
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
||||
const { context } = await getStaticSiteContext(await props.params);
|
||||
const context = await getStaticSiteContext(await props.params);
|
||||
return generateSitePageViewport(context);
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const { context } = await getStaticSiteContext(params);
|
||||
const context = await getStaticSiteContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
return generateSitePageMetadata({
|
||||
+4
-4
@@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
|
||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||
'use cache';
|
||||
|
||||
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
|
||||
const context = await getStaticSiteContext(await params);
|
||||
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
@@ -33,7 +33,7 @@ export default async function SiteStaticLayout({
|
||||
<SiteLayout
|
||||
context={context}
|
||||
withTracking={!GITBOOK_DISABLE_TRACKING}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
visitorAuthToken={null}
|
||||
>
|
||||
{children}
|
||||
</SiteLayout>
|
||||
@@ -42,11 +42,11 @@ export default async function SiteStaticLayout({
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const context = await getStaticSiteContext(await params);
|
||||
return generateSiteLayoutViewport(context);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const context = await getStaticSiteContext(await params);
|
||||
return generateSiteLayoutMetadata(context);
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const context = await getStaticSiteContext(await params);
|
||||
return serveLLMsTxt(context);
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const context = await getStaticSiteContext(await params);
|
||||
return serveRobotsTxt(context);
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const context = await getStaticSiteContext(await params);
|
||||
return servePagesSitemap(context);
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const context = await getStaticSiteContext(await params);
|
||||
return serveRootSitemap(context);
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const context = await getStaticSiteContext(await params);
|
||||
return serveIcon(context, request);
|
||||
}
|
||||
+1
-1
@@ -10,6 +10,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const context = await getStaticSiteContext(await params);
|
||||
return serveOGImage(context, await params);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
|
||||
import type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api';
|
||||
import { fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { forbidden } from 'next/navigation';
|
||||
import rison from 'rison';
|
||||
import {
|
||||
fetchSiteContextByURL,
|
||||
fetchSiteContextByURLLookup,
|
||||
getBaseContext,
|
||||
} from '@v2/lib/context';
|
||||
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||
|
||||
export type RouteParamMode = 'url-host' | 'url';
|
||||
|
||||
@@ -12,9 +12,6 @@ export type RouteLayoutParams = {
|
||||
|
||||
/** URL encoded site URL */
|
||||
siteURL: string;
|
||||
|
||||
/** URL and Rison encoded site data from getPublishedContentByUrl */
|
||||
siteData: string;
|
||||
};
|
||||
|
||||
export type RouteParams = RouteLayoutParams & {
|
||||
@@ -24,30 +21,19 @@ export type RouteParams = RouteLayoutParams & {
|
||||
/**
|
||||
* Get the static context when rendering statically a site.
|
||||
*/
|
||||
export async function getStaticSiteContext(params: RouteLayoutParams) {
|
||||
export function getStaticSiteContext(params: RouteLayoutParams) {
|
||||
const siteURL = getSiteURLFromParams(params);
|
||||
const siteURLData = getSiteURLDataFromParams(params);
|
||||
|
||||
// For static routes, we check the expiration of the JWT token
|
||||
// as the route might be revalidated after expiration
|
||||
const decoded = jwtDecode<SiteAPIToken & { exp: number }>(siteURLData.apiToken);
|
||||
if (decoded.exp && decoded.exp < Date.now() / 1000 + 120) {
|
||||
forbidden();
|
||||
}
|
||||
|
||||
const context = await fetchSiteContextByURLLookup(
|
||||
return fetchSiteContextByURL(
|
||||
getBaseContext({
|
||||
siteURL,
|
||||
siteURLData,
|
||||
urlMode: getModeFromParams(params.mode),
|
||||
}),
|
||||
siteURLData
|
||||
{
|
||||
url: siteURL.toString(),
|
||||
visitorAuthToken: null,
|
||||
redirectOnError: false,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
context,
|
||||
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,21 +42,15 @@ export async function getStaticSiteContext(params: RouteLayoutParams) {
|
||||
*/
|
||||
export async function getDynamicSiteContext(params: RouteLayoutParams) {
|
||||
const siteURL = getSiteURLFromParams(params);
|
||||
const siteURLData = getSiteURLDataFromParams(params);
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const context = await fetchSiteContextByURLLookup(
|
||||
return fetchSiteContextByURLLookup(
|
||||
getBaseContext({
|
||||
siteURL,
|
||||
siteURLData,
|
||||
urlMode: getModeFromParams(params.mode),
|
||||
}),
|
||||
siteURLData
|
||||
);
|
||||
|
||||
return {
|
||||
context,
|
||||
visitorAuthClaims: getVisitorAuthClaims(siteURLData),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,11 +74,3 @@ function getModeFromParams(mode: string): RouteParamMode {
|
||||
|
||||
return 'url';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the decoded site data from the params.
|
||||
*/
|
||||
function getSiteURLDataFromParams(params: RouteLayoutParams): PublishedSiteContent {
|
||||
const decoded = decodeURIComponent(params.siteData);
|
||||
return rison.decode(decoded);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
GITBOOK_APP_URL,
|
||||
GITBOOK_ASSETS_URL,
|
||||
GITBOOK_DISABLE_TRACKING,
|
||||
GITBOOK_FONTS_URL,
|
||||
GITBOOK_ICONS_URL,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
GITBOOK_INTEGRATIONS_HOST,
|
||||
@@ -26,7 +25,6 @@ export async function GET(_req: NextRequest) {
|
||||
GITBOOK_API_URL,
|
||||
GITBOOK_API_PUBLIC_URL,
|
||||
GITBOOK_ASSETS_URL,
|
||||
GITBOOK_FONTS_URL,
|
||||
GITBOOK_ICONS_URL,
|
||||
GITBOOK_USER_AGENT,
|
||||
GITBOOK_INTEGRATIONS_HOST,
|
||||
|
||||
@@ -20,10 +20,8 @@ export async function getSpacePDFContext(
|
||||
|
||||
const apiToken = await getAPITokenFromMiddleware();
|
||||
|
||||
const basePath = getPDFRoutePath(params);
|
||||
const linker = createLinker({
|
||||
spaceBasePath: basePath,
|
||||
siteBasePath: basePath,
|
||||
pathname: getPDFRoutePath(params),
|
||||
});
|
||||
const dataFetcher = createDataFetcher({
|
||||
apiToken: apiToken,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getSiteStructureSections } from '@/lib/sites';
|
||||
import type {
|
||||
ChangeRequest,
|
||||
PublishedSiteContent,
|
||||
PublishedSiteContentLookup,
|
||||
RevisionPage,
|
||||
RevisionPageDocument,
|
||||
Site,
|
||||
@@ -14,10 +14,11 @@ import type {
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import { type GitBookDataFetcher, createDataFetcher, throwIfDataError } from '@v2/lib/data';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { assert } from 'ts-essentials';
|
||||
import { GITBOOK_URL } from './env';
|
||||
import { type ImageResizer, createImageResizer } from './images';
|
||||
import { type GitBookLinker, createLinker } from './links';
|
||||
import { type GitBookSpaceLinker, createLinker } from './links';
|
||||
|
||||
/**
|
||||
* Generic context when rendering content.
|
||||
@@ -31,7 +32,7 @@ export type GitBookBaseContext = {
|
||||
/**
|
||||
* Linker to generate links in the current space.
|
||||
*/
|
||||
linker: GitBookLinker;
|
||||
linker: GitBookSpaceLinker;
|
||||
|
||||
/**
|
||||
* Image resizer to resize images.
|
||||
@@ -101,45 +102,27 @@ export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the base context for a request on a site.
|
||||
* Get the base context for a request.
|
||||
*/
|
||||
export function getBaseContext(input: {
|
||||
siteURL: URL | string;
|
||||
siteURLData: PublishedSiteContent;
|
||||
urlMode: 'url' | 'url-host';
|
||||
apiToken?: string | null;
|
||||
}) {
|
||||
const { urlMode, siteURLData } = input;
|
||||
const siteURL = typeof input.siteURL === 'string' ? new URL(input.siteURL) : input.siteURL;
|
||||
const url = typeof input.siteURL === 'string' ? new URL(input.siteURL) : input.siteURL;
|
||||
const urlMode = input.urlMode;
|
||||
|
||||
const dataFetcher = createDataFetcher({
|
||||
apiToken: siteURLData.apiToken ?? null,
|
||||
apiToken: input.apiToken ?? null,
|
||||
});
|
||||
|
||||
const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined;
|
||||
const linker =
|
||||
urlMode === 'url-host'
|
||||
? createLinker({
|
||||
host: siteURL.host,
|
||||
siteBasePath: siteURLData.siteBasePath,
|
||||
spaceBasePath: siteURLData.basePath,
|
||||
})
|
||||
: createLinker({
|
||||
protocol: gitbookURL?.protocol,
|
||||
host: gitbookURL?.host,
|
||||
siteBasePath: `/url/${siteURL.host}${siteURLData.siteBasePath}`,
|
||||
spaceBasePath: `/url/${siteURL.host}${siteURLData.basePath}`,
|
||||
});
|
||||
|
||||
if (urlMode === 'url') {
|
||||
// Create link in the same format for links to other sites/sections.
|
||||
linker.toLinkForContent = (rawURL: string) => {
|
||||
const urlObject = new URL(rawURL);
|
||||
return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}`;
|
||||
};
|
||||
}
|
||||
const linker = getLinkerForSiteURL({
|
||||
siteURL: url,
|
||||
urlMode,
|
||||
});
|
||||
|
||||
const imageResizer = createImageResizer({
|
||||
host: siteURL.host,
|
||||
host: url.host,
|
||||
// To ensure image resizing work for proxied sites,
|
||||
// we serve images from the root of the site.
|
||||
linker: linker,
|
||||
@@ -152,23 +135,92 @@ export function getBaseContext(input: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the linker for a given site URL.
|
||||
*/
|
||||
export function getLinkerForSiteURL(input: {
|
||||
siteURL: URL;
|
||||
urlMode: 'url' | 'url-host';
|
||||
}) {
|
||||
const { siteURL, urlMode } = input;
|
||||
|
||||
const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined;
|
||||
const linker =
|
||||
urlMode === 'url-host'
|
||||
? createLinker({
|
||||
host: siteURL.host,
|
||||
pathname: siteURL.pathname,
|
||||
})
|
||||
: createLinker({
|
||||
protocol: gitbookURL?.protocol,
|
||||
host: gitbookURL?.host,
|
||||
pathname: `/url/${siteURL.host}${siteURL.pathname}`,
|
||||
});
|
||||
|
||||
if (urlMode === 'url') {
|
||||
// Create link in the same format for links to other sites/sections.
|
||||
linker.toLinkForContent = (rawURL: string) => {
|
||||
const urlObject = new URL(rawURL);
|
||||
return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}`;
|
||||
};
|
||||
}
|
||||
|
||||
return linker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the context of a site for a given URL and a base context.
|
||||
*/
|
||||
export async function fetchSiteContextByURL(
|
||||
baseContext: GitBookBaseContext,
|
||||
input: {
|
||||
url: string;
|
||||
visitorAuthToken: string | null;
|
||||
redirectOnError: boolean;
|
||||
}
|
||||
): Promise<GitBookSiteContext> {
|
||||
const { dataFetcher } = baseContext;
|
||||
const data = await throwIfDataError(
|
||||
dataFetcher.getPublishedContentByUrl({
|
||||
url: input.url,
|
||||
visitorAuthToken: input.visitorAuthToken,
|
||||
redirectOnError: input.redirectOnError,
|
||||
})
|
||||
);
|
||||
|
||||
return fetchSiteContextByURLLookup(baseContext, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the context of a site using the resolution of a URL
|
||||
*/
|
||||
export async function fetchSiteContextByURLLookup(
|
||||
baseContext: GitBookBaseContext,
|
||||
data: PublishedSiteContent
|
||||
data: PublishedSiteContentLookup
|
||||
): Promise<GitBookSiteContext> {
|
||||
return await fetchSiteContextByIds(baseContext, {
|
||||
organization: data.organization,
|
||||
site: data.site,
|
||||
siteSection: data.siteSection,
|
||||
siteSpace: data.siteSpace,
|
||||
space: data.space,
|
||||
shareKey: data.shareKey,
|
||||
changeRequest: data.changeRequest,
|
||||
revision: data.revision,
|
||||
});
|
||||
const { dataFetcher } = baseContext;
|
||||
if ('redirect' in data) {
|
||||
redirect(data.redirect);
|
||||
}
|
||||
|
||||
return await fetchSiteContextByIds(
|
||||
{
|
||||
...baseContext,
|
||||
dataFetcher: dataFetcher.withToken({
|
||||
apiToken: data.apiToken,
|
||||
}),
|
||||
},
|
||||
{
|
||||
organization: data.organization,
|
||||
site: data.site,
|
||||
siteSection: data.siteSection,
|
||||
siteSpace: data.siteSpace,
|
||||
space: data.space,
|
||||
shareKey: data.shareKey,
|
||||
changeRequest: data.changeRequest,
|
||||
revision: data.revision,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,13 +3,15 @@ import {
|
||||
type ComputedContentSource,
|
||||
GitBookAPI,
|
||||
type GitBookAPIServiceBinding,
|
||||
type RenderIntegrationUI,
|
||||
} from '@gitbook/api';
|
||||
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
|
||||
import {
|
||||
getCacheTag,
|
||||
getCacheTagForURL,
|
||||
getComputedContentSourceCacheTags,
|
||||
} from '@gitbook/cache-tags';
|
||||
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
|
||||
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
|
||||
import { wrapDataFetcherError } from './errors';
|
||||
import { memoize } from './memoize';
|
||||
import type { GitBookDataFetcher } from './types';
|
||||
|
||||
interface DataFetcherInput {
|
||||
@@ -142,7 +144,6 @@ export function createDataFetcher(
|
||||
organizationId: params.organizationId,
|
||||
spaceId: params.spaceId,
|
||||
source: params.source,
|
||||
seed: params.seed,
|
||||
})
|
||||
);
|
||||
},
|
||||
@@ -158,14 +159,6 @@ export function createDataFetcher(
|
||||
return trace('searchSiteContent', () => searchSiteContent(input, params));
|
||||
},
|
||||
|
||||
renderIntegrationUi(params) {
|
||||
return trace('renderIntegrationUi', () =>
|
||||
renderIntegrationUi(input, {
|
||||
integrationName: params.integrationName,
|
||||
request: params.request,
|
||||
})
|
||||
);
|
||||
},
|
||||
//
|
||||
// API that are not tied to the token
|
||||
// where the data is the same for all users
|
||||
@@ -173,27 +166,34 @@ export function createDataFetcher(
|
||||
getUserById(userId) {
|
||||
return trace('getUserById', () => getUserById({ apiToken: null }, { userId }));
|
||||
},
|
||||
getPublishedContentByUrl(params) {
|
||||
return trace('getPublishedContentByUrl', () =>
|
||||
getPublishedContentByUrl(
|
||||
{ apiToken: null },
|
||||
{
|
||||
url: params.url,
|
||||
visitorAuthToken: params.visitorAuthToken,
|
||||
redirectOnError: params.redirectOnError,
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const getUserById = memoize(async function getUserById(
|
||||
input: DataFetcherInput,
|
||||
params: { userId: string }
|
||||
) {
|
||||
async function getUserById(input: DataFetcherInput, params: { userId: string }) {
|
||||
'use cache';
|
||||
|
||||
return trace('getUserById.uncached', () => {
|
||||
cacheLife('days');
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.users.getUserById(params.userId);
|
||||
return res.data;
|
||||
});
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.users.getUserById(params.userId);
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const getSpace = memoize(async function getSpace(
|
||||
async function getSpace(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -202,26 +202,24 @@ const getSpace = memoize(async function getSpace(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getSpace.uncached', () => {
|
||||
cacheLife('days');
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'space',
|
||||
space: params.spaceId,
|
||||
})
|
||||
);
|
||||
cacheLife('days');
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'space',
|
||||
space: params.spaceId,
|
||||
})
|
||||
);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getSpaceById(params.spaceId, {
|
||||
shareKey: params.shareKey,
|
||||
});
|
||||
return res.data;
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getSpaceById(params.spaceId, {
|
||||
shareKey: params.shareKey,
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const getChangeRequest = memoize(async function getChangeRequest(
|
||||
async function getChangeRequest(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -230,28 +228,23 @@ const getChangeRequest = memoize(async function getChangeRequest(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getChangeRequest.uncached', () => {
|
||||
cacheLife('minutes');
|
||||
cacheLife('minutes');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getChangeRequestById(
|
||||
params.spaceId,
|
||||
params.changeRequestId
|
||||
);
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'change-request',
|
||||
space: params.spaceId,
|
||||
changeRequest: res.data.id,
|
||||
})
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getChangeRequestById(params.spaceId, params.changeRequestId);
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'change-request',
|
||||
space: params.spaceId,
|
||||
changeRequest: res.data.id,
|
||||
})
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const getRevision = memoize(async function getRevision(
|
||||
async function getRevision(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -261,20 +254,18 @@ const getRevision = memoize(async function getRevision(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getRevision.uncached', () => {
|
||||
cacheLife('max');
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getRevisionById(params.spaceId, params.revisionId, {
|
||||
metadata: params.metadata,
|
||||
});
|
||||
return res.data;
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getRevisionById(params.spaceId, params.revisionId, {
|
||||
metadata: params.metadata,
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const getRevisionPages = memoize(async function getRevisionPages(
|
||||
async function getRevisionPages(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -284,24 +275,18 @@ const getRevisionPages = memoize(async function getRevisionPages(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getRevisionPages.uncached', () => {
|
||||
cacheLife('max');
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.listPagesInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
{
|
||||
metadata: params.metadata,
|
||||
}
|
||||
);
|
||||
return res.data.pages;
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.listPagesInRevisionById(params.spaceId, params.revisionId, {
|
||||
metadata: params.metadata,
|
||||
});
|
||||
return res.data.pages;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const getRevisionFile = memoize(async function getRevisionFile(
|
||||
async function getRevisionFile(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -311,23 +296,21 @@ const getRevisionFile = memoize(async function getRevisionFile(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getRevisionFile.uncached', () => {
|
||||
cacheLife('max');
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getFileInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
params.fileId,
|
||||
{}
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getFileInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
params.fileId,
|
||||
{}
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const getRevisionPageByPath = memoize(async function getRevisionPageByPath(
|
||||
async function getRevisionPageByPath(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -337,25 +320,23 @@ const getRevisionPageByPath = memoize(async function getRevisionPageByPath(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getRevisionPageByPath.uncached', () => {
|
||||
cacheLife('max');
|
||||
cacheLife('max');
|
||||
|
||||
const encodedPath = encodeURIComponent(params.path);
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getPageInRevisionByPath(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
encodedPath,
|
||||
{}
|
||||
);
|
||||
const encodedPath = encodeURIComponent(params.path);
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getPageInRevisionByPath(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
encodedPath,
|
||||
{}
|
||||
);
|
||||
|
||||
return res.data;
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const getDocument = memoize(async function getDocument(
|
||||
async function getDocument(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -364,53 +345,47 @@ const getDocument = memoize(async function getDocument(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getDocument.uncached', () => {
|
||||
cacheLife('max');
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getDocumentById(params.spaceId, params.documentId, {});
|
||||
return res.data;
|
||||
});
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getDocumentById(params.spaceId, params.documentId, {});
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const getComputedDocument = memoize(async function getComputedDocument(
|
||||
async function getComputedDocument(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
organizationId: string;
|
||||
source: ComputedContentSource;
|
||||
seed: string;
|
||||
}
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getComputedDocument.uncached', () => {
|
||||
cacheLife('days');
|
||||
cacheLife('days');
|
||||
|
||||
cacheTag(
|
||||
...getComputedContentSourceCacheTags(
|
||||
{
|
||||
spaceId: params.spaceId,
|
||||
organizationId: params.organizationId,
|
||||
},
|
||||
params.source
|
||||
)
|
||||
);
|
||||
cacheTag(
|
||||
...getComputedContentSourceCacheTags(
|
||||
{
|
||||
spaceId: params.spaceId,
|
||||
organizationId: params.organizationId,
|
||||
},
|
||||
params.source
|
||||
)
|
||||
);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getComputedDocument(params.spaceId, {
|
||||
source: params.source,
|
||||
seed: params.seed,
|
||||
});
|
||||
return res.data;
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getComputedDocument(params.spaceId, {
|
||||
source: params.source,
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const getReusableContent = memoize(async function getReusableContent(
|
||||
async function getReusableContent(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -420,54 +395,84 @@ const getReusableContent = memoize(async function getReusableContent(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getReusableContent.uncached', () => {
|
||||
cacheLife('max');
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getReusableContentInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
params.reusableContentId
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getReusableContentInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
params.reusableContentId
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const getLatestOpenAPISpecVersionContent = memoize(
|
||||
async function getLatestOpenAPISpecVersionContent(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
organizationId: string;
|
||||
slug: string;
|
||||
}
|
||||
) {
|
||||
'use cache';
|
||||
async function getLatestOpenAPISpecVersionContent(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
organizationId: string;
|
||||
slug: string;
|
||||
}
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getLatestOpenAPISpecVersionContent.uncached', () => {
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'openapi',
|
||||
organization: params.organizationId,
|
||||
openAPISpec: params.slug,
|
||||
})
|
||||
);
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.getLatestOpenApiSpecVersionContent(
|
||||
params.organizationId,
|
||||
params.slug
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
async function getPublishedContentByUrl(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
url: string;
|
||||
visitorAuthToken: string | null;
|
||||
redirectOnError: boolean;
|
||||
}
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
const { url, visitorAuthToken, redirectOnError } = params;
|
||||
|
||||
cacheTag(getCacheTagForURL(url));
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.urls.getPublishedContentByUrl({
|
||||
url,
|
||||
visitorAuthToken: visitorAuthToken ?? undefined,
|
||||
redirectOnError,
|
||||
});
|
||||
|
||||
if ('site' in res.data) {
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'openapi',
|
||||
organization: params.organizationId,
|
||||
openAPISpec: params.slug,
|
||||
tag: 'site',
|
||||
site: res.data.site,
|
||||
})
|
||||
);
|
||||
cacheLife('days');
|
||||
}
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.getLatestOpenApiSpecVersionContent(
|
||||
params.organizationId,
|
||||
params.slug
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
const getPublishedContentSite = memoize(async function getPublishedContentSite(
|
||||
async function getPublishedContentSite(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
organizationId: string;
|
||||
@@ -477,32 +482,24 @@ const getPublishedContentSite = memoize(async function getPublishedContentSite(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getPublishedContentSite.uncached', () => {
|
||||
cacheLife('days');
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: params.siteId,
|
||||
})
|
||||
);
|
||||
cacheLife('days');
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: params.siteId,
|
||||
})
|
||||
);
|
||||
|
||||
return trace('getPublishedContentSite', () => {
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.getPublishedContentSite(
|
||||
params.organizationId,
|
||||
params.siteId,
|
||||
{
|
||||
shareKey: params.siteShareKey,
|
||||
}
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.getPublishedContentSite(params.organizationId, params.siteId, {
|
||||
shareKey: params.siteShareKey,
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const getSiteRedirectBySource = memoize(async function getSiteRedirectBySource(
|
||||
async function getSiteRedirectBySource(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
organizationId: string;
|
||||
@@ -513,32 +510,26 @@ const getSiteRedirectBySource = memoize(async function getSiteRedirectBySource(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getSiteRedirectBySource.uncached', () => {
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: params.siteId,
|
||||
})
|
||||
);
|
||||
cacheLife('days');
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: params.siteId,
|
||||
})
|
||||
);
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.getSiteRedirectBySource(
|
||||
params.organizationId,
|
||||
params.siteId,
|
||||
{
|
||||
shareKey: params.siteShareKey,
|
||||
source: params.source,
|
||||
}
|
||||
);
|
||||
|
||||
return res.data;
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.getSiteRedirectBySource(params.organizationId, params.siteId, {
|
||||
shareKey: params.siteShareKey,
|
||||
source: params.source,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const getEmbedByUrl = memoize(async function getEmbedByUrl(
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
async function getEmbedByUrl(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
url: string;
|
||||
@@ -547,62 +538,34 @@ const getEmbedByUrl = memoize(async function getEmbedByUrl(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getEmbedByUrl.uncached', () => {
|
||||
cacheLife('weeks');
|
||||
cacheLife('weeks');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
|
||||
return res.data;
|
||||
});
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const searchSiteContent = memoize(async function searchSiteContent(
|
||||
async function searchSiteContent(
|
||||
input: DataFetcherInput,
|
||||
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('searchSiteContent.uncached', () => {
|
||||
const { organizationId, siteId, query, scope } = params;
|
||||
const { organizationId, siteId, query, scope } = params;
|
||||
|
||||
cacheLife('days');
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.searchSiteContent(organizationId, siteId, {
|
||||
query,
|
||||
...scope,
|
||||
});
|
||||
return res.data.items;
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.searchSiteContent(organizationId, siteId, {
|
||||
query,
|
||||
...scope,
|
||||
});
|
||||
return res.data.items;
|
||||
});
|
||||
});
|
||||
|
||||
const renderIntegrationUi = memoize(async function renderIntegrationUi(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
integrationName: string;
|
||||
request: RenderIntegrationUI;
|
||||
}
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('renderIntegrationUi.uncached', () => {
|
||||
cacheTag(getCacheTag({ tag: 'integration', integration: params.integrationName }));
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.integrations.renderIntegrationUiWithPost(
|
||||
params.integrationName,
|
||||
params.request
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let loggedServiceBinding = false;
|
||||
|
||||
|
||||
@@ -15,14 +15,13 @@ export async function getPublishedContentByURL(input: {
|
||||
url: string;
|
||||
visitorAuthToken: string | null;
|
||||
redirectOnError: boolean;
|
||||
apiToken: string | null;
|
||||
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
|
||||
const lookupURL = new URL(input.url);
|
||||
const url = stripURLSearch(lookupURL);
|
||||
const lookup = getURLLookupAlternatives(url);
|
||||
|
||||
const result = await race(lookup.urls, async (alternative, { signal }) => {
|
||||
const api = await apiClient({ apiToken: input.apiToken });
|
||||
const api = await apiClient();
|
||||
|
||||
const callResult = await trace(
|
||||
{
|
||||
@@ -95,15 +94,12 @@ export async function getPublishedContentByURL(input: {
|
||||
* In both cases, the idea is to use the deepest/longest/most inclusive path to resolve the content.
|
||||
*/
|
||||
if (alternative.primary || ('site' in data && data.complete)) {
|
||||
const changeRequest = data.changeRequest ?? lookup.changeRequest;
|
||||
const revision = data.revision ?? lookup.revision;
|
||||
|
||||
const siteResult: PublishedSiteContentLookup = {
|
||||
...data,
|
||||
changeRequest: data.changeRequest ?? lookup.changeRequest,
|
||||
revision: data.revision ?? lookup.revision,
|
||||
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
|
||||
pathname: joinPath(data.pathname, alternative.extraPath),
|
||||
...(changeRequest ? { changeRequest } : {}),
|
||||
...(revision ? { revision } : {}),
|
||||
};
|
||||
return { data: siteResult };
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import { memoize } from './memoize';
|
||||
|
||||
describe('memoize', () => {
|
||||
it('should memoize the function', async () => {
|
||||
const fn = mock(async () => Math.random());
|
||||
const memoized = memoize(fn);
|
||||
expect(await memoized()).toBe(await memoized());
|
||||
});
|
||||
|
||||
it('should memoize the function with different arguments', async () => {
|
||||
const fn = mock(async (a: number, b: number) => a + b);
|
||||
const memoized = memoize(fn);
|
||||
expect(await memoized(1, 2)).toBe(await memoized(1, 2));
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
expect(await memoized(1, 2)).not.toBe(await memoized(2, 3));
|
||||
expect(fn.mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should memoize a function complex object', async () => {
|
||||
const fn = mock(async (a: { foo: string; bar: number }) => a.foo + a.bar);
|
||||
const memoized = memoize(fn);
|
||||
expect(await memoized({ foo: 'foo', bar: 1 })).toBe(await memoized({ foo: 'foo', bar: 1 }));
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
expect(await memoized({ foo: 'foo', bar: 1 })).not.toBe(
|
||||
await memoized({ foo: 'foo', bar: 2 })
|
||||
);
|
||||
expect(fn.mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should wrap concurrent async calls', async () => {
|
||||
const fn = mock(async () => Math.random());
|
||||
const memoized = memoize(fn);
|
||||
const promise1 = memoized();
|
||||
const promise2 = memoized();
|
||||
expect(await promise1).toBe(await promise2);
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import pMemoize from 'p-memoize';
|
||||
|
||||
/**
|
||||
* We wrap 'use cache' calls in a p-memoize function to avoid
|
||||
* executing the function multiple times when doing concurrent calls.
|
||||
*
|
||||
* Hopefully one day this can be done directly by 'use cache'.
|
||||
*/
|
||||
export function memoize<F extends (...args: any[]) => any>(f: F): F {
|
||||
return pMemoize(f, {
|
||||
cacheKey: (args) => {
|
||||
return JSON.stringify(deepSortValue(args));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function deepSortValue(value: unknown): unknown {
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean' ||
|
||||
value === null ||
|
||||
value === undefined
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(deepSortValue);
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.entries(value)
|
||||
.map(([key, subValue]) => {
|
||||
return [key, deepSortValue(subValue)] as const;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -15,13 +15,12 @@ export async function getPageDocument(
|
||||
dataFetcher.getDocument({ spaceId: space.id, documentId: page.documentId })
|
||||
);
|
||||
}
|
||||
if ('computed' in page && page.computed) {
|
||||
if (page.computed) {
|
||||
return getDataOrNull(
|
||||
dataFetcher.getComputedDocument({
|
||||
organizationId: space.organization,
|
||||
spaceId: space.id,
|
||||
source: page.computed,
|
||||
seed: page.computedSeed,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,15 @@ export interface GitBookDataFetcher {
|
||||
*/
|
||||
getUserById(userId: string): Promise<DataFetcherResponse<api.User>>;
|
||||
|
||||
/**
|
||||
* Get a published content by its URL.
|
||||
*/
|
||||
getPublishedContentByUrl(params: {
|
||||
url: string;
|
||||
visitorAuthToken: string | null;
|
||||
redirectOnError: boolean;
|
||||
}): Promise<DataFetcherResponse<api.PublishedSiteContentLookup>>;
|
||||
|
||||
/**
|
||||
* Get a published content site by its organization ID and site ID.
|
||||
*/
|
||||
@@ -111,7 +120,6 @@ export interface GitBookDataFetcher {
|
||||
organizationId: string;
|
||||
spaceId: string;
|
||||
source: api.ComputedContentSource;
|
||||
seed: string;
|
||||
}): Promise<DataFetcherResponse<api.JSONDocument>>;
|
||||
|
||||
/**
|
||||
@@ -162,12 +170,4 @@ export interface GitBookDataFetcher {
|
||||
/** Cache bust to ensure the search results are fresh when the space is updated. */
|
||||
cacheBust?: string;
|
||||
}): Promise<DataFetcherResponse<api.SearchSpaceResult[]>>;
|
||||
|
||||
/**
|
||||
* Render an integration UI.
|
||||
*/
|
||||
renderIntegrationUi(params: {
|
||||
integrationName: string;
|
||||
request: api.RenderIntegrationUI;
|
||||
}): Promise<DataFetcherResponse<api.ContentKitRenderOutput>>;
|
||||
}
|
||||
|
||||
-5
@@ -64,11 +64,6 @@ export const GITBOOK_DISABLE_TRACKING = Boolean(
|
||||
export const GITBOOK_INTEGRATIONS_HOST =
|
||||
process.env.GITBOOK_INTEGRATIONS_HOST || 'integrations.gitbook.com';
|
||||
|
||||
/**
|
||||
* Hostname for fonts.
|
||||
*/
|
||||
export const GITBOOK_FONTS_URL = process.env.GITBOOK_FONTS_URL || 'https://fonts.gitbook.com';
|
||||
|
||||
/**
|
||||
* Endpoint to use for resizing images.
|
||||
* It should be a Cloudflare domain with image resizing enabled.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'server-only';
|
||||
|
||||
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
|
||||
import type { GitBookLinker } from '../links';
|
||||
import type { GitBookSpaceLinker } from '../links';
|
||||
import { type SignatureVersion, generateImageSignature } from './signatures';
|
||||
import type { ImageResizer } from './types';
|
||||
|
||||
@@ -37,7 +37,7 @@ export function createImageResizer({
|
||||
linker,
|
||||
}: {
|
||||
/** The linker to use to create URLs. */
|
||||
linker: GitBookLinker;
|
||||
linker: GitBookSpaceLinker;
|
||||
/** The host name of the current site. */
|
||||
host: string;
|
||||
}): ImageResizer {
|
||||
@@ -62,7 +62,7 @@ export function createImageResizer({
|
||||
url: urlInput,
|
||||
});
|
||||
|
||||
const url = linker.toAbsoluteURL(linker.toPathInSite('/~gitbook/image'));
|
||||
const url = linker.toAbsoluteURL(linker.toPathInContent('/~gitbook/image'));
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('url', getImageAPIUrl(urlInput));
|
||||
|
||||
|
||||
@@ -1,51 +1,25 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { createLinker } from './links';
|
||||
import { appendBasePathToLinker, createLinker } from './links';
|
||||
|
||||
const root = createLinker({
|
||||
host: 'docs.company.com',
|
||||
spaceBasePath: '/',
|
||||
siteBasePath: '/',
|
||||
pathname: '/',
|
||||
});
|
||||
|
||||
const variantInSection = createLinker({
|
||||
host: 'docs.company.com',
|
||||
spaceBasePath: '/section/variant',
|
||||
siteBasePath: '/',
|
||||
});
|
||||
|
||||
const siteGitBookIO = createLinker({
|
||||
host: 'org.gitbook.io',
|
||||
spaceBasePath: '/sitename/variant/',
|
||||
siteBasePath: '/sitename/',
|
||||
pathname: '/section/variant',
|
||||
});
|
||||
|
||||
describe('toPathInContent', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(root.toPathInSpace('some/path')).toBe('/some/path');
|
||||
expect(variantInSection.toPathInSpace('some/path')).toBe('/section/variant/some/path');
|
||||
expect(root.toPathInContent('some/path')).toBe('/some/path');
|
||||
expect(variantInSection.toPathInContent('some/path')).toBe('/section/variant/some/path');
|
||||
});
|
||||
|
||||
it('should handle leading slash', () => {
|
||||
expect(root.toPathInSpace('/some/path')).toBe('/some/path');
|
||||
expect(variantInSection.toPathInSpace('/some/path')).toBe('/section/variant/some/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPathInSite', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(root.toPathInSite('some/path')).toBe('/some/path');
|
||||
expect(siteGitBookIO.toPathInSite('some/path')).toBe('/sitename/some/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toRelativePathInSite', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(root.toRelativePathInSite('/some/path')).toBe('some/path');
|
||||
expect(siteGitBookIO.toRelativePathInSite('/sitename/some/path')).toBe('some/path');
|
||||
});
|
||||
|
||||
it('should preserve absolute paths outside of the site', () => {
|
||||
expect(siteGitBookIO.toRelativePathInSite('/outside/some/path')).toBe('/outside/some/path');
|
||||
expect(root.toPathInContent('/some/path')).toBe('/some/path');
|
||||
expect(variantInSection.toPathInContent('/some/path')).toBe('/section/variant/some/path');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,17 +32,27 @@ describe('toAbsoluteURL', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('toLinkForContent', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(root.toLinkForContent('https://docs.company.com/some/path')).toBe('/some/path');
|
||||
expect(siteGitBookIO.toLinkForContent('https://org.gitbook.io/sitename/some/path')).toBe(
|
||||
'/sitename/some/path'
|
||||
);
|
||||
describe('appendBasePathToLinker', () => {
|
||||
const prefixedRoot = appendBasePathToLinker(root, '/section/variant');
|
||||
const prefixedVariantInSection = appendBasePathToLinker(variantInSection, '/base');
|
||||
|
||||
describe('toPathInContent', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(prefixedRoot.toPathInContent('some/path')).toBe('/section/variant/some/path');
|
||||
expect(prefixedVariantInSection.toPathInContent('some/path')).toBe(
|
||||
'/section/variant/base/some/path'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve an absolute URL if the site is not the same', () => {
|
||||
expect(siteGitBookIO.toLinkForContent('https://org.gitbook.io/anothersite/some/path')).toBe(
|
||||
'https://org.gitbook.io/anothersite/some/path'
|
||||
);
|
||||
describe('toAbsoluteURL', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(prefixedRoot.toAbsoluteURL('some/path')).toBe(
|
||||
'https://docs.company.com/some/path'
|
||||
);
|
||||
expect(prefixedVariantInSection.toAbsoluteURL('some/path')).toBe(
|
||||
'https://docs.company.com/some/path'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
|
||||
import type { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
|
||||
import warnOnce from 'warn-once';
|
||||
|
||||
@@ -8,28 +7,17 @@ import warnOnce from 'warn-once';
|
||||
*
|
||||
* URL levels:
|
||||
*
|
||||
* https://docs.company.com/basename/section/variant/page
|
||||
* https://docs.company.com/section/variant/page
|
||||
*
|
||||
* toPathInSpace('some/path') => /basename/section/variant/some/path
|
||||
* toPathInSite('some/path') => /basename/some/path
|
||||
* toPathForPage({ pages, page }) => /basename/section/variant/some/path
|
||||
* toPathInContent('some/path') => /section/variant/some/path
|
||||
* toPathForPage({ pages, page }) => /section/variant/some/path
|
||||
* toAbsoluteURL('some/path') => https://docs.company.com/some/path
|
||||
*/
|
||||
export interface GitBookLinker {
|
||||
export interface GitBookSpaceLinker {
|
||||
/**
|
||||
* Generate an absolute path for a relative path to the current content.
|
||||
*/
|
||||
toPathInSpace(relativePath: string): string;
|
||||
|
||||
/**
|
||||
* Generate an absolute path for a relative path to the current site.
|
||||
*/
|
||||
toPathInSite(relativePath: string): string;
|
||||
|
||||
/**
|
||||
* Transform an absolute path in a site, to a relative path from the root of the site.
|
||||
*/
|
||||
toRelativePathInSite(absolutePath: string): string;
|
||||
toPathInContent(relativePath: string): string;
|
||||
|
||||
/**
|
||||
* Generate an absolute path for a page in the current content.
|
||||
@@ -60,36 +48,14 @@ export function createLinker(
|
||||
servedOn: {
|
||||
protocol?: string;
|
||||
host?: string;
|
||||
|
||||
/** The base path of the space */
|
||||
spaceBasePath: string;
|
||||
|
||||
/** The base path of the site */
|
||||
siteBasePath: string;
|
||||
pathname: string;
|
||||
}
|
||||
): GitBookLinker {
|
||||
): GitBookSpaceLinker {
|
||||
warnOnce(!servedOn.host, 'No host provided to createLinker. It can lead to issues with links.');
|
||||
|
||||
const siteBasePath = withTrailingSlash(withLeadingSlash(servedOn.siteBasePath));
|
||||
const spaceBasePath = withTrailingSlash(withLeadingSlash(servedOn.spaceBasePath));
|
||||
|
||||
const linker: GitBookLinker = {
|
||||
toPathInSpace(relativePath: string): string {
|
||||
return joinPaths(spaceBasePath, relativePath);
|
||||
},
|
||||
|
||||
toPathInSite(relativePath: string): string {
|
||||
return joinPaths(siteBasePath, relativePath);
|
||||
},
|
||||
|
||||
toRelativePathInSite(absolutePath: string): string {
|
||||
const normalizedPath = withLeadingSlash(absolutePath);
|
||||
|
||||
if (!normalizedPath.startsWith(servedOn.siteBasePath)) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
return normalizedPath.slice(servedOn.siteBasePath.length);
|
||||
const linker: GitBookSpaceLinker = {
|
||||
toPathInContent(relativePath: string): string {
|
||||
return joinPaths(servedOn.pathname, relativePath);
|
||||
},
|
||||
|
||||
toAbsoluteURL(absolutePath: string): string {
|
||||
@@ -101,25 +67,48 @@ export function createLinker(
|
||||
},
|
||||
|
||||
toPathForPage({ pages, page, anchor }) {
|
||||
return linker.toPathInSpace(getPagePath(pages, page)) + (anchor ? `#${anchor}` : '');
|
||||
return linker.toPathInContent(getPagePath(pages, page)) + (anchor ? `#${anchor}` : '');
|
||||
},
|
||||
|
||||
toLinkForContent(rawURL: string): string {
|
||||
const url = new URL(rawURL);
|
||||
|
||||
// If the link points to a content in the same site, we return an absolute path
|
||||
// instead of a full URL; it makes it possible to use router navigation
|
||||
if (url.hostname === servedOn.host && url.pathname.startsWith(servedOn.siteBasePath)) {
|
||||
return url.pathname;
|
||||
}
|
||||
|
||||
return rawURL;
|
||||
toLinkForContent(url: string): string {
|
||||
return url;
|
||||
},
|
||||
};
|
||||
|
||||
return linker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a prefix to a linker.
|
||||
*/
|
||||
export function appendBasePathToLinker(
|
||||
linker: GitBookSpaceLinker,
|
||||
basePath: string
|
||||
): GitBookSpaceLinker {
|
||||
const linkerWithPrefix: GitBookSpaceLinker = {
|
||||
toPathInContent(relativePath: string): string {
|
||||
return linker.toPathInContent(joinPaths(basePath, relativePath));
|
||||
},
|
||||
|
||||
toAbsoluteURL(absolutePath: string): string {
|
||||
return linker.toAbsoluteURL(absolutePath);
|
||||
},
|
||||
|
||||
toPathForPage({ pages, page, anchor }) {
|
||||
return (
|
||||
linkerWithPrefix.toPathInContent(getPagePath(pages, page)) +
|
||||
(anchor ? `#${anchor}` : '')
|
||||
);
|
||||
},
|
||||
|
||||
toLinkForContent(url: string): string {
|
||||
return linker.toLinkForContent(url);
|
||||
},
|
||||
};
|
||||
|
||||
return linkerWithPrefix;
|
||||
}
|
||||
|
||||
function joinPaths(prefix: string, path: string): string {
|
||||
const prefixPath = prefix.endsWith('/') ? prefix : `${prefix}/`;
|
||||
const suffixPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
|
||||
@@ -32,6 +32,11 @@ export enum MiddlewareHeaders {
|
||||
*/
|
||||
Customization = 'x-gitbook-customization',
|
||||
|
||||
/**
|
||||
* The visitor token used to access this content
|
||||
*/
|
||||
VisitorAuthToken = 'x-gitbook-visitor-token',
|
||||
|
||||
/**
|
||||
* The API token used to fetch the content.
|
||||
* This should only be passed for non-site dynamic routes.
|
||||
@@ -100,6 +105,20 @@ export async function getThemeFromMiddleware() {
|
||||
: CustomizationThemeMode.Dark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visitor auth token from the middleware headers.
|
||||
* This function should only be called in a dynamic route.
|
||||
*/
|
||||
export async function getVisitorAuthTokenFromMiddleware(): Promise<string | null> {
|
||||
const headersList = await headers();
|
||||
const visitorAuthToken = headersList.get(MiddlewareHeaders.VisitorAuthToken);
|
||||
if (!visitorAuthToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return visitorAuthToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API token from the middleware headers.
|
||||
* This function should only be called in a dynamic route.
|
||||
|
||||
@@ -16,8 +16,8 @@ export async function getServerActionBaseContext() {
|
||||
|
||||
return getBaseContext({
|
||||
siteURL,
|
||||
siteURLData,
|
||||
urlMode,
|
||||
apiToken: siteURLData.apiToken,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CustomizationThemeMode } from '@gitbook/api';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import rison from 'rison';
|
||||
|
||||
import { getContentSecurityPolicy } from '@/lib/csp';
|
||||
import { validateSerializedCustomization } from '@/lib/customization';
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
normalizeVisitorAuthURL,
|
||||
} from '@/lib/visitor-token';
|
||||
import { serveResizedImage } from '@/routes/image';
|
||||
import { getLinkerForSiteURL } from '@v2/lib/context';
|
||||
import {
|
||||
DataFetcherError,
|
||||
getPublishedContentByURL,
|
||||
@@ -41,7 +41,7 @@ export async function middleware(request: NextRequest) {
|
||||
return NextResponse.redirect(normalized.toString());
|
||||
}
|
||||
|
||||
for (const handler of [serveSiteRoutes, serveSpacePDFRoutes]) {
|
||||
for (const handler of [serveSiteRoutes, servePreviewRoutes]) {
|
||||
const result = await handler(requestURL, request);
|
||||
if (result) {
|
||||
return result;
|
||||
@@ -83,182 +83,181 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
// Detect and extract the visitor authentication token from the request
|
||||
//
|
||||
// @ts-ignore - request typing
|
||||
const visitorToken = getVisitorToken({
|
||||
cookies: request.cookies.getAll(),
|
||||
url: siteURL,
|
||||
});
|
||||
const visitorToken = getVisitorToken(request, siteURL);
|
||||
|
||||
const withAPIToken = async (apiToken: string | null) => {
|
||||
const siteURLData = await throwIfDataError(
|
||||
getPublishedContentByURL({
|
||||
url: siteURL.toString(),
|
||||
visitorAuthToken: visitorToken?.token ?? null,
|
||||
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
|
||||
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
|
||||
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
|
||||
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
|
||||
const data = await throwIfDataError(
|
||||
getPublishedContentByURL({
|
||||
url: siteURL.toString(),
|
||||
visitorAuthToken: visitorToken?.token ?? null,
|
||||
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
|
||||
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
|
||||
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
|
||||
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
|
||||
})
|
||||
);
|
||||
let cookies: ResponseCookies = {};
|
||||
|
||||
// Use the API token passed in the request, if any
|
||||
// as it could be used for .preview hostnames
|
||||
apiToken,
|
||||
})
|
||||
);
|
||||
const cookies: ResponseCookies = [];
|
||||
//
|
||||
// Handle redirects
|
||||
//
|
||||
if ('redirect' in data) {
|
||||
// biome-ignore lint/suspicious/noConsole: we want to log the redirect
|
||||
console.log('redirect', data.redirect);
|
||||
if (data.target === 'content') {
|
||||
// For content redirects, we use the linker to redirect the optimal URL
|
||||
// during development and testing in 'url' mode.
|
||||
const linker = getLinkerForSiteURL({
|
||||
siteURL,
|
||||
urlMode: mode,
|
||||
});
|
||||
|
||||
//
|
||||
// Handle redirects
|
||||
//
|
||||
if ('redirect' in siteURLData) {
|
||||
// biome-ignore lint/suspicious/noConsole: we want to log the redirect
|
||||
console.log('redirect', siteURLData.redirect);
|
||||
if (siteURLData.target === 'content') {
|
||||
let contentRedirect = new URL(siteURLData.redirect, request.url);
|
||||
const contentRedirect = new URL(linker.toLinkForContent(data.redirect), request.url);
|
||||
|
||||
// For content redirects, we redirect using the /url/:url format
|
||||
// during development and testing in 'url' mode.
|
||||
if (mode === 'url') {
|
||||
const urlObject = new URL(siteURLData.redirect);
|
||||
contentRedirect = new URL(
|
||||
`/url/${urlObject.host}${urlObject.pathname}${urlObject.search}`,
|
||||
request.url
|
||||
);
|
||||
}
|
||||
// Keep the same search params as the original request
|
||||
// as it might contain a VA token
|
||||
contentRedirect.search = request.nextUrl.search;
|
||||
|
||||
// Keep the same search params as the original request
|
||||
// as it might contain a VA token
|
||||
contentRedirect.search = request.nextUrl.search;
|
||||
|
||||
return NextResponse.redirect(contentRedirect);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(siteURLData.redirect);
|
||||
return NextResponse.redirect(contentRedirect);
|
||||
}
|
||||
|
||||
cookies.push(...getResponseCookiesForVisitorAuth(siteURLData.siteBasePath, visitorToken));
|
||||
return NextResponse.redirect(data.redirect);
|
||||
}
|
||||
|
||||
//
|
||||
// Make sure the URL is clean of any va token after a successful lookup
|
||||
// The token is stored in a cookie that is set on the redirect response
|
||||
//
|
||||
const incomingURL = mode === 'url' ? requestURL : siteURL;
|
||||
const requestURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
|
||||
if (
|
||||
requestURLWithoutToken !== incomingURL &&
|
||||
requestURLWithoutToken.toString() !== incomingURL.toString()
|
||||
) {
|
||||
return writeResponseCookies(
|
||||
NextResponse.redirect(requestURLWithoutToken.toString()),
|
||||
cookies
|
||||
);
|
||||
}
|
||||
|
||||
//
|
||||
// Render and serve the content
|
||||
//
|
||||
|
||||
// The route is static, except when using dynamic parameters from query params
|
||||
// (customization override, theme, etc)
|
||||
let routeType: 'dynamic' | 'static' = 'static';
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
||||
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
|
||||
requestHeaders.set(MiddlewareHeaders.SiteURL, `${siteURL.origin}${siteURLData.basePath}`);
|
||||
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(siteURLData));
|
||||
|
||||
// Preview of customization/theme
|
||||
const customization = siteURL.searchParams.get('customization');
|
||||
if (customization && validateSerializedCustomization(customization)) {
|
||||
routeType = 'dynamic';
|
||||
requestHeaders.set(MiddlewareHeaders.Customization, customization);
|
||||
}
|
||||
const theme = siteURL.searchParams.get('theme');
|
||||
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
|
||||
routeType = 'dynamic';
|
||||
requestHeaders.set(MiddlewareHeaders.Theme, theme);
|
||||
}
|
||||
|
||||
// We support forcing dynamic routes by setting a `gitbook-dynamic-route` cookie
|
||||
// This is useful for testing dynamic routes.
|
||||
if (request.cookies.has('gitbook-dynamic-route')) {
|
||||
routeType = 'dynamic';
|
||||
}
|
||||
|
||||
// Pass a x-forwarded-host and origin that are equal to ensure Next doesn't block server actions when proxied
|
||||
requestHeaders.set('x-forwarded-host', request.nextUrl.host);
|
||||
requestHeaders.set('origin', request.nextUrl.origin);
|
||||
|
||||
const siteURLWithoutProtocol = `${siteURL.host}${siteURLData.basePath}`;
|
||||
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
|
||||
siteURLData.pathname
|
||||
);
|
||||
routeType = routeTypeFromPathname ?? routeType;
|
||||
|
||||
const route = [
|
||||
'sites',
|
||||
routeType,
|
||||
mode,
|
||||
encodeURIComponent(siteURLWithoutProtocol),
|
||||
encodeURIComponent(rison.encode(siteURLData)),
|
||||
pathname,
|
||||
].join('/');
|
||||
|
||||
console.log(`rewriting ${request.nextUrl.toString()} to ${route}`);
|
||||
|
||||
const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString());
|
||||
const response = NextResponse.rewrite(rewrittenURL, {
|
||||
request: {
|
||||
headers: requestHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
// Add Content Security Policy header
|
||||
response.headers.set('content-security-policy', getContentSecurityPolicy());
|
||||
// Basic security headers
|
||||
response.headers.set('strict-transport-security', 'max-age=31536000');
|
||||
response.headers.set('referrer-policy', 'no-referrer-when-downgrade');
|
||||
response.headers.set('x-content-type-options', 'nosniff');
|
||||
// Debug header
|
||||
response.headers.set('x-gitbook-route-type', routeType);
|
||||
response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol);
|
||||
|
||||
return writeResponseCookies(response, cookies);
|
||||
cookies = {
|
||||
...cookies,
|
||||
...getResponseCookiesForVisitorAuth(data.basePath, visitorToken),
|
||||
};
|
||||
|
||||
// For https://preview/<siteURL> requests,
|
||||
if (siteURL.hostname === 'preview') {
|
||||
return serveWithQueryAPIToken(
|
||||
// We scope the API token to the site ID.
|
||||
`${siteURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`,
|
||||
request,
|
||||
withAPIToken
|
||||
//
|
||||
// Make sure the URL is clean of any va token after a successful lookup
|
||||
// The token is stored in a cookie that is set on the redirect response
|
||||
//
|
||||
const requestURLWithoutToken = normalizeVisitorAuthURL(requestURL);
|
||||
if (requestURLWithoutToken.toString() !== requestURL.toString()) {
|
||||
return writeResponseCookies(
|
||||
NextResponse.redirect(requestURLWithoutToken.toString()),
|
||||
cookies
|
||||
);
|
||||
}
|
||||
|
||||
return withAPIToken(null);
|
||||
//
|
||||
// Render and serve the content
|
||||
//
|
||||
|
||||
// When visitor has authentication (adaptive content or VA), we serve dynamic routes.
|
||||
let routeType = visitorToken ? 'dynamic' : 'static';
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
||||
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
|
||||
requestHeaders.set(MiddlewareHeaders.SiteURL, `${siteURL.origin}${data.basePath}`);
|
||||
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(data));
|
||||
|
||||
// Preview of customization/theme
|
||||
const customization = siteURL.searchParams.get('customization');
|
||||
if (customization && validateSerializedCustomization(customization)) {
|
||||
routeType = 'dynamic';
|
||||
requestHeaders.set(MiddlewareHeaders.Customization, customization);
|
||||
}
|
||||
const theme = siteURL.searchParams.get('theme');
|
||||
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
|
||||
routeType = 'dynamic';
|
||||
requestHeaders.set(MiddlewareHeaders.Theme, theme);
|
||||
}
|
||||
|
||||
// We support forcing dynamic routes by setting a `gitbook-dynamic-route` cookie
|
||||
// This is useful for testing dynamic routes.
|
||||
if (request.cookies.has('gitbook-dynamic-route')) {
|
||||
routeType = 'dynamic';
|
||||
}
|
||||
|
||||
// Pass a x-forwarded-host and origin that are equal to ensure Next doesn't block server actions when proxied
|
||||
requestHeaders.set('x-forwarded-host', request.nextUrl.host);
|
||||
requestHeaders.set('origin', request.nextUrl.origin);
|
||||
|
||||
const siteURLWithoutProtocol = `${siteURL.host}${data.basePath}`;
|
||||
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(data.pathname);
|
||||
routeType = routeTypeFromPathname ?? routeType;
|
||||
|
||||
const route = [
|
||||
'sites',
|
||||
routeType,
|
||||
mode,
|
||||
encodeURIComponent(siteURLWithoutProtocol),
|
||||
pathname,
|
||||
].join('/');
|
||||
|
||||
console.log(`rewriting ${request.nextUrl.toString()} to ${route}`);
|
||||
|
||||
const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString());
|
||||
const response = NextResponse.rewrite(rewrittenURL, {
|
||||
request: {
|
||||
headers: requestHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
// Add Content Security Policy header
|
||||
response.headers.set('content-security-policy', getContentSecurityPolicy());
|
||||
// Basic security headers
|
||||
response.headers.set('strict-transport-security', 'max-age=31536000');
|
||||
response.headers.set('referrer-policy', 'no-referrer-when-downgrade');
|
||||
response.headers.set('x-content-type-options', 'nosniff');
|
||||
// Debug header
|
||||
response.headers.set('x-gitbook-route-type', routeType);
|
||||
response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol);
|
||||
|
||||
return writeResponseCookies(response, cookies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve routes for PDF export for a space: /~space/:spaceId/~gitbook/pdf
|
||||
* Serve routes for previewing unpublished content.
|
||||
* Routes are:
|
||||
* - PDF export for a space: /~space/:spaceId/~gitbook/pdf
|
||||
* - Preview of an unpublished site: /~site/:siteId/
|
||||
*/
|
||||
async function serveSpacePDFRoutes(requestURL: URL, request: NextRequest) {
|
||||
async function servePreviewRoutes(requestURL: URL, request: NextRequest) {
|
||||
const pathnameParts = requestURL.pathname.slice(1).split('/');
|
||||
|
||||
if (pathnameParts[0] !== '~space' && pathnameParts[0] !== '~site') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return serveWithQueryAPIToken(
|
||||
pathnameParts.slice(0, 2).join('/'),
|
||||
request,
|
||||
async (apiToken) => {
|
||||
// Handle the rest with the router default logic
|
||||
return NextResponse.next({
|
||||
headers: {
|
||||
[MiddlewareHeaders.APIToken]: apiToken,
|
||||
},
|
||||
});
|
||||
}
|
||||
// We store the API token in a cookie that is scoped to the specific preview route
|
||||
// to avoid errors when multiple previews are opened in different tabs.
|
||||
const cookieName = getPathScopedCookieName(
|
||||
'gitbook-api-token',
|
||||
pathnameParts.slice(0, 2).join('/')
|
||||
);
|
||||
|
||||
// Extract a potential GitBook API token passed in the request
|
||||
// If found, we redirect to the same URL but with the token in the cookie
|
||||
const queryAPIToken = requestURL.searchParams.get('token');
|
||||
if (queryAPIToken) {
|
||||
requestURL.searchParams.delete('token');
|
||||
return writeResponseCookies(NextResponse.redirect(requestURL.toString()), {
|
||||
[cookieName]: {
|
||||
value: queryAPIToken,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60, // 1 hour
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = request.cookies.get(cookieName)?.value;
|
||||
if (!apiToken) {
|
||||
throw new DataFetcherError('Missing API token', 400);
|
||||
}
|
||||
|
||||
// Handle the rest with the router default logic
|
||||
return NextResponse.next({
|
||||
headers: {
|
||||
[MiddlewareHeaders.APIToken]: apiToken,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -275,45 +274,6 @@ function serveErrorResponse(error: Error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server a response with an API token obtained from the query params.
|
||||
*/
|
||||
async function serveWithQueryAPIToken(
|
||||
scopePath: string,
|
||||
request: NextRequest,
|
||||
serve: (apiToken: string) => Promise<NextResponse>
|
||||
) {
|
||||
// We store the API token in a cookie that is scoped to the specific route
|
||||
// to avoid errors when multiple previews are opened in different tabs.
|
||||
const cookieName = getPathScopedCookieName('gitbook-api-token', scopePath);
|
||||
|
||||
// Extract a potential GitBook API token passed in the request
|
||||
// If found, we redirect to the same URL but with the token in the cookie
|
||||
const queryAPIToken = request.nextUrl.searchParams.get('token');
|
||||
if (queryAPIToken) {
|
||||
request.nextUrl.searchParams.delete('token');
|
||||
return writeResponseCookies(NextResponse.redirect(request.nextUrl.toString()), [
|
||||
{
|
||||
name: cookieName,
|
||||
value: queryAPIToken,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60, // 1 hour
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const apiToken = request.cookies.get(cookieName)?.value;
|
||||
if (!apiToken) {
|
||||
throw new DataFetcherError('Missing API token', 400);
|
||||
}
|
||||
|
||||
return serve(apiToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL of the GitBook content can be passed in 3 different ways (in order of priority):
|
||||
* - The request has a `X-GitBook-URL` header:
|
||||
@@ -352,12 +312,6 @@ function getSiteURLFromRequest(request: NextRequest): URLWithMode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
'requestHeaders',
|
||||
request.headers.entries().map(([key, value]) => [key, value])
|
||||
);
|
||||
console.log('nextUrl', request.nextUrl.toString());
|
||||
|
||||
const xForwardedHost = request.headers.get('x-forwarded-host');
|
||||
// The x-forwarded-host is set by Vercel for all requests
|
||||
// so we ignore it if the hostname is the same as the instance one.
|
||||
@@ -392,7 +346,6 @@ function encodePathInSiteContent(rawPathname: string): {
|
||||
case '~gitbook/icon':
|
||||
case 'llms.txt':
|
||||
case 'sitemap.xml':
|
||||
case 'sitemap-pages.xml':
|
||||
case 'robots.txt':
|
||||
return { pathname };
|
||||
case '~gitbook/pdf':
|
||||
@@ -418,8 +371,8 @@ function appendQueryParams(url: URL, from: URLSearchParams) {
|
||||
* Write the cookies to a response.
|
||||
*/
|
||||
function writeResponseCookies<R extends NextResponse>(response: R, cookies: ResponseCookies): R {
|
||||
cookies.forEach((cookie) => {
|
||||
response.cookies.set(cookie.name, cookie.value, cookie.options);
|
||||
Object.entries(cookies).forEach(([key, { value, options }]) => {
|
||||
response.cookies.set(key, value, options);
|
||||
});
|
||||
|
||||
return response;
|
||||
|
||||
@@ -1,29 +1,5 @@
|
||||
# gitbook
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ed07206: Fix OpenAPI path overflow on mobile
|
||||
- 4dab1c5: Fix alignment of `prominent` search bar on full-width pages
|
||||
- 6eae764: Support body examples
|
||||
- 54ee014: Add initial support for loading custom fonts
|
||||
- d2facb2: Mark properties as optional if not required
|
||||
- bba2e52: Fix site redirects when it includes a section/variant path
|
||||
- 4723f03: Restyle section group dropdown
|
||||
- 24b7808: Fix `prominent` search bar width on `md` screens
|
||||
- 1fe3286: Fix OpenAPI block overflow issue
|
||||
- Updated dependencies [48c18c0]
|
||||
- Updated dependencies [6eae764]
|
||||
- Updated dependencies [7212973]
|
||||
- Updated dependencies [d2facb2]
|
||||
- Updated dependencies [73e2b47]
|
||||
- Updated dependencies [70be2c6]
|
||||
- Updated dependencies [fc00b51]
|
||||
- Updated dependencies [a84b06b]
|
||||
- @gitbook/openapi-parser@2.1.1
|
||||
- @gitbook/react-openapi@1.1.6
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type TestsCase, runTestCases, waitForCookiesDialog } from './util';
|
||||
import { type TestsCase, runTestCases } from './util';
|
||||
|
||||
/** A list of test cases to run on the customers' docs sites. */
|
||||
const testCases: TestsCase[] = [
|
||||
@@ -6,21 +6,14 @@ const testCases: TestsCase[] = [
|
||||
name: 'Snyk',
|
||||
contentBaseURL: 'https://docs.snyk.io',
|
||||
tests: [
|
||||
{ name: 'Home', url: '/', run: waitForCookiesDialog },
|
||||
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'OpenAPI', url: '/snyk-api/reference/apps' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Nexthink',
|
||||
contentBaseURL: 'https://docs.nexthink.com',
|
||||
tests: [
|
||||
{
|
||||
name: 'Home',
|
||||
url: '/',
|
||||
screenshot: { waitForTOCScrolling: false },
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
tests: [{ name: 'Home', url: '/', screenshot: { waitForTOCScrolling: false } }],
|
||||
},
|
||||
{
|
||||
name: 'asiksupport-stg.dto.kemkes.go.id',
|
||||
@@ -40,7 +33,7 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'docs.dify.ai',
|
||||
contentBaseURL: 'https://docs.dify.ai',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'seeddao.gitbook.io',
|
||||
@@ -70,27 +63,27 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'docs.portainer.io',
|
||||
contentBaseURL: 'https://docs.portainer.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.chirptoken.io',
|
||||
contentBaseURL: 'https://docs.chirptoken.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.dexscreener.com',
|
||||
contentBaseURL: 'https://docs.dexscreener.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.pancakeswap.finance',
|
||||
contentBaseURL: 'https://docs.pancakeswap.finance',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'book.character.ai',
|
||||
contentBaseURL: 'https://book.character.ai',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.tradeonnova.io',
|
||||
@@ -110,7 +103,7 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'docs.keeper.io',
|
||||
contentBaseURL: 'https://docs.keeper.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'adiblar.gitbook.io',
|
||||
@@ -145,12 +138,12 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'meshnet.nordvpn.com',
|
||||
contentBaseURL: 'https://meshnet.nordvpn.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'manual.bubble.io',
|
||||
contentBaseURL: 'https://manual.bubble.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.tickettool.xyz',
|
||||
@@ -165,12 +158,12 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'docs.cherry-ai.com',
|
||||
contentBaseURL: 'https://docs.cherry-ai.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.snyk.io',
|
||||
contentBaseURL: 'https://docs.snyk.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.realapp.link',
|
||||
@@ -195,14 +188,7 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'docs.umbraco.com',
|
||||
contentBaseURL: 'https://docs.umbraco.com',
|
||||
tests: [
|
||||
{
|
||||
name: 'Home',
|
||||
url: '/welcome',
|
||||
run: waitForCookiesDialog,
|
||||
screenshot: { waitForTOCScrolling: false },
|
||||
},
|
||||
],
|
||||
tests: [{ name: 'Home', url: '/welcome', screenshot: { waitForTOCScrolling: false } }],
|
||||
},
|
||||
{
|
||||
name: 'sosovalue-white-paper.gitbook.io',
|
||||
@@ -212,12 +198,12 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'docs.revrobotics.com',
|
||||
contentBaseURL: 'https://docs.revrobotics.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'chartschool.stockcharts.com',
|
||||
contentBaseURL: 'https://chartschool.stockcharts.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.soniclabs.com',
|
||||
@@ -232,7 +218,7 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'docs.thousandeyes.com',
|
||||
contentBaseURL: 'https://docs.thousandeyes.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.raydium.io',
|
||||
@@ -242,7 +228,7 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'docs.fluentbit.io',
|
||||
contentBaseURL: 'https://docs.fluentbit.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -367,29 +367,6 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Site Preview',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
tests: [
|
||||
{
|
||||
name: 'Main content',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken(
|
||||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||||
);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/preview/${data.site}/?${searchParams.toString()}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Content tests',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
@@ -409,7 +386,7 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/block-images',
|
||||
run: waitForCookiesDialog,
|
||||
fullPage: true,
|
||||
screenshot: { threshold: 0.9 },
|
||||
screenshot: { threshold: 0.8 },
|
||||
},
|
||||
{
|
||||
name: 'Images (with zoom)',
|
||||
@@ -420,6 +397,7 @@ const testCases: TestsCase[] = [
|
||||
await zoomImage.first().click();
|
||||
await expect(page.getByTestId('zoom-image-modal')).toBeVisible();
|
||||
},
|
||||
fullPage: true,
|
||||
screenshot: { threshold: 0.8 },
|
||||
},
|
||||
{
|
||||
@@ -454,12 +432,7 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'Integration Blocks',
|
||||
url: 'blocks/integrations',
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const mermaidIframe = page.locator('iframe[title*="mermaid"]').contentFrame();
|
||||
await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible();
|
||||
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
|
||||
},
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Tables',
|
||||
@@ -808,21 +781,6 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Site Redirects with sections',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
|
||||
tests: [
|
||||
{
|
||||
// This test that a redirect that incudes a section path works
|
||||
name: 'Redirect to Quickstart page',
|
||||
url: 'sections-2/redirect-test',
|
||||
run: async (page) => {
|
||||
await expect(page.locator('h1')).toHaveText('Quickstart');
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Share links',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/gbo-tests-share-links/',
|
||||
|
||||
@@ -63,7 +63,6 @@ export interface Test {
|
||||
|
||||
export type TestsCase = {
|
||||
name: string;
|
||||
skip?: boolean;
|
||||
tests: Array<Test>;
|
||||
contentBaseURL?: string;
|
||||
};
|
||||
@@ -134,8 +133,11 @@ export const headerLinks: CustomizationHeaderItem[] = [
|
||||
];
|
||||
|
||||
export async function waitForCookiesDialog(page: Page) {
|
||||
const dialog = page.getByTestId('cookies-dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
const dialog = page.getByRole('dialog', { name: 'Cookies' });
|
||||
const accept = dialog.getByRole('button', { name: 'Accept' });
|
||||
const reject = dialog.getByRole('button', { name: 'Reject' });
|
||||
await expect(accept).toBeVisible();
|
||||
await expect(reject).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,10 +145,6 @@ export async function waitForCookiesDialog(page: Page) {
|
||||
*/
|
||||
export function runTestCases(testCases: TestsCase[]) {
|
||||
for (const testCase of testCases) {
|
||||
if (testCase.skip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
test.describe(testCase.name, () => {
|
||||
for (const testEntry of testCase.tests) {
|
||||
const testFn = testEntry.only ? test.only : test;
|
||||
@@ -192,15 +190,14 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
|
||||
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
.intercom-lightweight-app {
|
||||
display: none !important;
|
||||
/* Hide Intercom */
|
||||
.intercom-lightweight-app {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
threshold: screenshotOptions?.threshold ?? undefined,
|
||||
fullPage: testEntry.fullPage ?? false,
|
||||
beforeScreenshot: async ({ runStabilization }) => {
|
||||
await runStabilization();
|
||||
beforeScreenshot: async () => {
|
||||
await waitForIcons(page);
|
||||
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
||||
await waitForTOCScrolling(page);
|
||||
@@ -322,66 +319,29 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
* Wait for all icons present on the page to be loaded.
|
||||
*/
|
||||
async function waitForIcons(page: Page) {
|
||||
await page.waitForFunction(() => {
|
||||
const urls = new Set<string>();
|
||||
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
|
||||
const results = icons.map((icon) => {
|
||||
if (!(icon instanceof SVGElement)) {
|
||||
throw new Error('Icon is not an SVGElement');
|
||||
}
|
||||
|
||||
// If loaded, good it passes the test.
|
||||
if (icon.dataset.loadingState === 'loaded') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If not loaded yet, we need to load it.
|
||||
if (icon.dataset.loadingState === 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ignore icons that are not visible.
|
||||
if (!icon.checkVisibility()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
|
||||
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
|
||||
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
|
||||
const url = urlMatch ? urlMatch[1] : null;
|
||||
|
||||
// If URL is invalid we throw an error.
|
||||
if (!url) {
|
||||
throw new Error('No mask-image');
|
||||
}
|
||||
|
||||
// If the URL is already loaded, we just mark it as loaded.
|
||||
if (urls.has(url)) {
|
||||
icon.dataset.loadingState = 'loaded';
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mark the icon as pending and load the image.
|
||||
icon.dataset.loadingState = 'pending';
|
||||
|
||||
// Mark the URL as seen.
|
||||
urls.add(url);
|
||||
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
img.decode().then(() => {
|
||||
// Wait two frames to let the time to the icon to repaint.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
icon.dataset.loadingState = 'loaded';
|
||||
});
|
||||
});
|
||||
await page.waitForFunction(async () => {
|
||||
function loadImage(src: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (_error) => reject(new Error(`Failed to load image: ${src}`));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return results.every((x) => x);
|
||||
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
|
||||
await Promise.all(
|
||||
icons.map(async (icon) => {
|
||||
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
|
||||
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
|
||||
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
|
||||
const url = urlMatch ? urlMatch[1] : null;
|
||||
if (!url) {
|
||||
throw new Error('No mask-image');
|
||||
}
|
||||
await loadImage(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "env-cmd --silent -f ../../.env.local next dev",
|
||||
@@ -33,7 +33,6 @@
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@upstash/redis": "^1.27.1",
|
||||
"ai": "^4.1.46",
|
||||
"ajv": "^8.12.0",
|
||||
"assert-never": "^1.2.1",
|
||||
"bun-types": "^1.1.20",
|
||||
@@ -41,12 +40,13 @@
|
||||
"framer-motion": "^10.16.14",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"memoizee": "^0.4.17",
|
||||
"next": "14.2.25",
|
||||
"memoizee": "^0.4.15",
|
||||
"micromark": "^4.0.1",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"next": "14.2.23",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
@@ -56,25 +56,22 @@
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"rison": "^0.1.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "^3.2.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwind-shades": "^1.1.2",
|
||||
"unified": "^11.0.5",
|
||||
"url-join": "^5.0.0",
|
||||
"usehooks-ts": "^3.1.0"
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"ai": "^4.1.46",
|
||||
"jwt-decode": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^4.3.0",
|
||||
"@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.51.1",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsontoxml": "^1.0.5",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
@@ -92,10 +89,8 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"postcss": "^8",
|
||||
"psi": "^4.1.0",
|
||||
"stylelint": "^16.16.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"ts-essentials": "^10.0.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vercel": "^39.3.0"
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,5 @@ export default defineConfig({
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
contextOptions: {
|
||||
reducedMotion: 'reduce',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getSiteURLDataFromMiddleware, getThemeFromMiddleware } from '@v2/lib/middleware';
|
||||
import { getThemeFromMiddleware, getVisitorAuthTokenFromMiddleware } from '@v2/lib/middleware';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import type React from 'react';
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
generateSiteLayoutMetadata,
|
||||
generateSiteLayoutViewport,
|
||||
} from '@/components/SiteLayout';
|
||||
import { getVisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { shouldTrackEvents } from '@/lib/tracking';
|
||||
import { fetchV1ContextForSitePointer } from '@/lib/v1';
|
||||
@@ -23,14 +22,14 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
|
||||
const context = await fetchLayoutData();
|
||||
const queryStringTheme = await getThemeFromMiddleware();
|
||||
const siteData = await getSiteURLDataFromMiddleware();
|
||||
const visitorAuthToken = await getVisitorAuthTokenFromMiddleware();
|
||||
|
||||
return (
|
||||
<SiteLayout
|
||||
context={context}
|
||||
forcedTheme={queryStringTheme}
|
||||
withTracking={await shouldTrackEvents()}
|
||||
visitorAuthClaims={getVisitorAuthClaims(siteData)}
|
||||
visitorAuthToken={visitorAuthToken}
|
||||
>
|
||||
{children}
|
||||
</SiteLayout>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
|
||||
const { nodes, blockStyle, isOffscreen: defaultIsOffscreen = false, ...contextProps } = props;
|
||||
|
||||
let isOffscreen = defaultIsOffscreen;
|
||||
return nodes.map((node, index) => {
|
||||
return nodes.map((node) => {
|
||||
isOffscreen =
|
||||
isOffscreen ||
|
||||
isBlockOffscreen({
|
||||
@@ -65,7 +65,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
|
||||
|
||||
return (
|
||||
<Block
|
||||
key={node.key || `${node.type}-${index}`}
|
||||
key={node.key}
|
||||
block={node}
|
||||
style={[
|
||||
'mx-auto w-full decoration-primary/6',
|
||||
|
||||
@@ -24,16 +24,14 @@ export function Inlines<T extends DocumentInline | DocumentText>(
|
||||
) {
|
||||
const { nodes, document, ancestorInlines, ...contextProps } = props;
|
||||
|
||||
return nodes.map((node, index) => {
|
||||
const key = node.key || `key-${index}`;
|
||||
|
||||
return nodes.map((node) => {
|
||||
if (node.object === 'text') {
|
||||
return <Text key={key} text={node} />;
|
||||
return <Text key={node.key} text={node} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Inline
|
||||
key={key}
|
||||
key={node.key}
|
||||
inline={node}
|
||||
document={document}
|
||||
ancestorInlines={ancestorInlines}
|
||||
|
||||
@@ -1,13 +1,47 @@
|
||||
import type { ContentKitContext, DocumentBlockIntegration } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import {
|
||||
ContentKit,
|
||||
ContentKitOutput,
|
||||
type ContentKitServerContext,
|
||||
} from '@gitbook/react-contentkit';
|
||||
|
||||
import { renderIntegrationUi } from '@/lib/api';
|
||||
import { parseMarkdown } from '@/lib/markdown';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
|
||||
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
|
||||
import { GITBOOK_INTEGRATIONS_HOST } from '@v2/lib/env';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import { PlainCodeBlock } from '../CodeBlock';
|
||||
import './contentkit.css';
|
||||
import { getDataOrNull } from '@v2/lib/data';
|
||||
import { contentKitServerContext } from './contentkit';
|
||||
import { renderIntegrationUi } from './server-actions';
|
||||
import { ignoreAllThrownError } from '@v2/lib/data';
|
||||
|
||||
const outputContext: ContentKitServerContext = {
|
||||
icons: {
|
||||
maximize: (props) => <Icon icon="maximize" {...props} />,
|
||||
edit: (props) => <Icon icon="edit" {...props} />,
|
||||
github: (props) => <Icon icon="github" {...props} />,
|
||||
gitlab: (props) => <Icon icon="gitlab" {...props} />,
|
||||
close: (props) => <Icon icon="x" {...props} />,
|
||||
email: (props) => <Icon icon="envelope" {...props} />,
|
||||
settings: (props) => <Icon icon="gear" {...props} />,
|
||||
search: (props) => <Icon icon="magnifying-glass" {...props} />,
|
||||
delete: (props) => <Icon icon="trash" {...props} />,
|
||||
star: (props) => <Icon icon="star" {...props} />,
|
||||
warning: (props) => <Icon icon="triangle-exclamation" {...props} />,
|
||||
link: (props) => <Icon icon="link" {...props} />,
|
||||
'link-external': (props) => <Icon icon="arrow-up-right-from-square" {...props} />,
|
||||
eye: (props) => <Icon icon="eye" {...props} />,
|
||||
lock: (props) => <Icon icon="lock" {...props} />,
|
||||
},
|
||||
codeBlock: (props) => {
|
||||
return <PlainCodeBlock code={props.code} syntax={props.syntax} />;
|
||||
},
|
||||
markdown: async ({ className, markdown }) => {
|
||||
const parsed = await parseMarkdown(markdown);
|
||||
return <div className={className} dangerouslySetInnerHTML={{ __html: parsed }} />;
|
||||
},
|
||||
};
|
||||
|
||||
export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) {
|
||||
const { block, context, style } = props;
|
||||
@@ -16,29 +50,22 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
throw new Error('integration block requires a content.spaceId');
|
||||
}
|
||||
|
||||
const { dataFetcher } = context.contentContext;
|
||||
const contentKitContext: ContentKitContext = {
|
||||
type: 'document',
|
||||
spaceId: context.contentContext?.space.id,
|
||||
editable: false,
|
||||
theme: 'light', // TODO: how to handle this without moving rendering to the client side?
|
||||
};
|
||||
|
||||
const initialInput: RenderIntegrationUI = {
|
||||
const initialInput = {
|
||||
componentId: block.data.block,
|
||||
props: block.data.props,
|
||||
action: block.data.action,
|
||||
context: {
|
||||
type: 'document',
|
||||
spaceId: context.contentContext?.space.id,
|
||||
editable: false,
|
||||
theme: 'light', // TODO: how to handle this without moving rendering to the client side?
|
||||
},
|
||||
context: contentKitContext,
|
||||
};
|
||||
|
||||
const initialOutput = await getDataOrNull(
|
||||
dataFetcher.renderIntegrationUi({
|
||||
integrationName: block.data.integration,
|
||||
request: initialInput,
|
||||
}),
|
||||
|
||||
// The API can respond with a 400 error if the integration is not installed
|
||||
// and 404 if the integration is not found.
|
||||
[404, 400]
|
||||
const initialOutput = await ignoreAllThrownError(
|
||||
renderIntegrationUi(block.data.integration, initialInput)
|
||||
);
|
||||
if (!initialOutput || initialOutput.type === 'complete') {
|
||||
return null;
|
||||
@@ -47,15 +74,21 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
return (
|
||||
<div className={tcls(style)}>
|
||||
<ContentKit
|
||||
renderContext={{
|
||||
integrationName: block.data.integration,
|
||||
}}
|
||||
security={{ firstPartyDomains: [GITBOOK_INTEGRATIONS_HOST] }}
|
||||
initialInput={initialInput}
|
||||
initialOutput={initialOutput}
|
||||
render={renderIntegrationUi}
|
||||
render={async (request) => {
|
||||
'use server';
|
||||
|
||||
const output = await renderIntegrationUi(block.data.integration, request);
|
||||
|
||||
return {
|
||||
children: <ContentKitOutput output={output} context={outputContext} />,
|
||||
output: output,
|
||||
};
|
||||
}}
|
||||
>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
<ContentKitOutput output={initialOutput} context={outputContext} />
|
||||
</ContentKit>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { parseMarkdown } from '@/lib/markdown';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import type { ContentKitServerContext } from '@gitbook/react-contentkit';
|
||||
import { PlainCodeBlock } from '../CodeBlock';
|
||||
|
||||
export const contentKitServerContext: ContentKitServerContext = {
|
||||
icons: {
|
||||
maximize: (props) => <Icon icon="maximize" {...props} />,
|
||||
edit: (props) => <Icon icon="edit" {...props} />,
|
||||
github: (props) => <Icon icon="github" {...props} />,
|
||||
gitlab: (props) => <Icon icon="gitlab" {...props} />,
|
||||
close: (props) => <Icon icon="x" {...props} />,
|
||||
email: (props) => <Icon icon="envelope" {...props} />,
|
||||
settings: (props) => <Icon icon="gear" {...props} />,
|
||||
search: (props) => <Icon icon="magnifying-glass" {...props} />,
|
||||
delete: (props) => <Icon icon="trash" {...props} />,
|
||||
star: (props) => <Icon icon="star" {...props} />,
|
||||
warning: (props) => <Icon icon="triangle-exclamation" {...props} />,
|
||||
link: (props) => <Icon icon="link" {...props} />,
|
||||
'link-external': (props) => <Icon icon="arrow-up-right-from-square" {...props} />,
|
||||
eye: (props) => <Icon icon="eye" {...props} />,
|
||||
lock: (props) => <Icon icon="lock" {...props} />,
|
||||
},
|
||||
codeBlock: (props) => {
|
||||
return <PlainCodeBlock code={props.code} syntax={props.syntax} />;
|
||||
},
|
||||
markdown: async ({ className, markdown }) => {
|
||||
const parsed = await parseMarkdown(markdown);
|
||||
return <div className={className} dangerouslySetInnerHTML={{ __html: parsed }} />;
|
||||
},
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getV1BaseContext } from '@/lib/v1';
|
||||
import { isV2 } from '@/lib/v2';
|
||||
import type { RenderIntegrationUI } from '@gitbook/api';
|
||||
import { ContentKitOutput } from '@gitbook/react-contentkit';
|
||||
import { throwIfDataError } from '@v2/lib/data';
|
||||
import { getServerActionBaseContext } from '@v2/lib/server-actions';
|
||||
import { contentKitServerContext } from './contentkit';
|
||||
|
||||
/**
|
||||
* Server action to render an integration UI request from <ContentKit />.
|
||||
* See `render` prop in <ContentKit /> for more details.
|
||||
*/
|
||||
export async function renderIntegrationUi({
|
||||
renderContext,
|
||||
request,
|
||||
}: {
|
||||
renderContext: {
|
||||
integrationName: string;
|
||||
};
|
||||
request: RenderIntegrationUI;
|
||||
}) {
|
||||
const serverAction = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||
|
||||
const output = await throwIfDataError(
|
||||
serverAction.dataFetcher.renderIntegrationUi({
|
||||
integrationName: renderContext.integrationName,
|
||||
request,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
children: <ContentKitOutput output={output} context={contentKitServerContext} />,
|
||||
output: output,
|
||||
};
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import type { AnyOpenAPIOperationsBlock } from '@/lib/openapi/types';
|
||||
export async function OpenAPIOperation(props: BlockProps<AnyOpenAPIOperationsBlock>) {
|
||||
const { style } = props;
|
||||
return (
|
||||
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
|
||||
<div className={tcls('flex w-full', style, 'max-w-full')}>
|
||||
<OpenAPIOperationBody {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Layout Components */
|
||||
.openapi-operation {
|
||||
@apply flex-1 flex flex-col gap-8 mb-14 min-w-0;
|
||||
@apply flex-1 flex flex-col gap-8 mb-14;
|
||||
}
|
||||
|
||||
.openapi-schemas {
|
||||
@@ -201,10 +201,6 @@
|
||||
@apply text-warning-subtle text-[0.813rem];
|
||||
}
|
||||
|
||||
.openapi-schema-optional {
|
||||
@apply text-info-subtle text-[0.813rem];
|
||||
}
|
||||
|
||||
.openapi-schema-readonly {
|
||||
@apply text-primary-subtle/9 text-[0.813rem];
|
||||
}
|
||||
@@ -396,18 +392,12 @@
|
||||
}
|
||||
|
||||
.openapi-codesample-footer {
|
||||
@apply flex gap-3 w-full justify-between flex-wrap;
|
||||
}
|
||||
|
||||
.openapi-codesample-selectors {
|
||||
@apply flex flex-row items-center gap-3 flex-wrap;
|
||||
@apply flex w-full justify-end;
|
||||
}
|
||||
|
||||
/* Path */
|
||||
.openapi-path {
|
||||
@apply flex items-start text-sm gap-2 h-fit overflow-x-auto min-w-0 max-w-full;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
@apply flex items-center text-sm gap-2 h-fit;
|
||||
}
|
||||
|
||||
.openapi-path-variable {
|
||||
@@ -419,13 +409,14 @@
|
||||
}
|
||||
|
||||
.openapi-path .openapi-method {
|
||||
@apply text-[0.813rem] m-0 mt-0.5 items-center flex px-2;
|
||||
@apply text-[0.813rem] m-0 h-full items-center flex px-2;
|
||||
}
|
||||
|
||||
.openapi-path-title {
|
||||
@apply flex-1 relative font-normal text-left font-mono text-tint-strong/10;
|
||||
@apply flex-1 relative font-normal whitespace-nowrap overflow-x-auto font-mono text-tint-strong/10;
|
||||
@apply py-0.5 px-1 rounded hover:bg-tint cursor-pointer transition-colors;
|
||||
@apply whitespace-nowrap md:whitespace-normal;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.openapi-path-title[data-deprecated="true"] {
|
||||
@@ -467,9 +458,13 @@
|
||||
|
||||
/* Common Elements */
|
||||
.openapi-select {
|
||||
@apply max-w-60 rounded font-mono text-xs leading-6 px-1 py-0.5 truncate border border-tint-subtle bg-tint;
|
||||
@apply max-w-60 rounded font-mono text-xs leading-6 px-1 py-0.5 border border-tint-subtle bg-tint;
|
||||
}
|
||||
|
||||
.openapi-select {
|
||||
min-width: fit-content;
|
||||
max-width: fit-content;
|
||||
}
|
||||
.openapi-select:focus {
|
||||
width: auto;
|
||||
}
|
||||
@@ -517,7 +512,7 @@
|
||||
|
||||
/* Tabs */
|
||||
.openapi-tabs-list {
|
||||
@apply flex flex-row gap-1.5 py-1.5 px-2.5 w-full overflow-x-scroll;
|
||||
@apply flex flex-row gap-1.5 py-1.5 px-2.5 border-b border-tint-subtle w-full overflow-x-scroll;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
@@ -527,12 +522,11 @@
|
||||
}
|
||||
|
||||
.openapi-tabs-tab[aria-selected="true"] {
|
||||
@apply text-primary after:absolute after:-bottom-[calc(0.375rem_+_1px)] after:z-20 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all;
|
||||
@apply text-primary after:absolute after:-bottom-2 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all;
|
||||
}
|
||||
|
||||
.openapi-tabs-panel {
|
||||
@apply flex-1 text-sm relative focus-visible:outline-none;
|
||||
@apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10;
|
||||
@apply flex-1 text-sm focus-visible:outline-none;
|
||||
}
|
||||
|
||||
.openapi-tabs-footer {
|
||||
|
||||
@@ -42,11 +42,11 @@ export function CurrentContentIcon(
|
||||
}
|
||||
: {
|
||||
light: {
|
||||
src: linker.toPathInSpace('~gitbook/icon?size=medium&theme=light'),
|
||||
src: linker.toPathInContent('~gitbook/icon?size=medium&theme=light'),
|
||||
size: { width: 256, height: 256 },
|
||||
},
|
||||
dark: {
|
||||
src: linker.toPathInSpace('~gitbook/icon?size=medium&theme=dark'),
|
||||
src: linker.toPathInContent('~gitbook/icon?size=medium&theme=dark'),
|
||||
size: { width: 256, height: 256 },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ export function DropdownMenuItem(
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
prefetch={false}
|
||||
insights={insights}
|
||||
className={tcls(
|
||||
'rounded straight-corners:rounded-sm px-3 py-1 text-sm',
|
||||
|
||||
@@ -102,14 +102,13 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo
|
||||
? [
|
||||
'md:grow-[0.8]',
|
||||
'lg:basis-40',
|
||||
'md:max-w-[40%]',
|
||||
'lg:max-w-lg',
|
||||
'max-w-lg',
|
||||
'lg:ml-[max(calc((100%-18rem-48rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - margin (3rem)
|
||||
'xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
|
||||
'page-no-toc:lg:ml-[max(calc((100%-18rem-48rem-18rem-3rem)/2),0rem)]',
|
||||
'page-full-width:lg:ml-[max(calc((100%-18rem-103rem-3rem)/2),1.5rem)]',
|
||||
'page-full-width:2xl:ml-[max(calc((100%-18rem-96rem-14rem+3rem)/2),1.5rem)]',
|
||||
'md:mr-auto',
|
||||
'lg:mr-auto',
|
||||
'order-last',
|
||||
'md:order-[unset]',
|
||||
]
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function HeaderLogo(props: HeaderLogoProps) {
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={linker.toAbsoluteURL(linker.toPathInSpace(''))}
|
||||
href={linker.toAbsoluteURL(linker.toPathInContent(''))}
|
||||
className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')}
|
||||
>
|
||||
{customization.header.logo ? (
|
||||
|
||||
@@ -47,11 +47,17 @@ export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLB
|
||||
aria-label={tString(language, 'table_of_contents_button_label')}
|
||||
onClick={toggleNavigation}
|
||||
className={tcls(
|
||||
'flex flex-row items-center rounded straight-corners:rounded-sm px-2 py-1',
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'py-1',
|
||||
'px-2',
|
||||
'rounded',
|
||||
'straight-corners:rounded-sm',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<Icon icon="bars" className="size-4 text-inherit" />
|
||||
<Icon icon="bars" className={tcls('size-4', 'text-inherit')} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
|
||||
import * as React from 'react';
|
||||
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
|
||||
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { getAllBrowserCookiesMap } from '@/lib/browser-cookies';
|
||||
import * as cookies from '@/lib/cookies';
|
||||
|
||||
import { getSession } from './sessions';
|
||||
import { getVisitorId } from './visitorId';
|
||||
|
||||
@@ -23,7 +23,6 @@ type InsightsEventContext = {
|
||||
siteShareKey: string | null;
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
visitorAuthClaims: VisitorAuthClaims;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -66,6 +65,7 @@ interface InsightsProviderProps extends InsightsEventContext {
|
||||
enabled: boolean;
|
||||
appURL: string;
|
||||
apiHost: string;
|
||||
visitorAuthToken: string | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ interface InsightsProviderProps extends InsightsEventContext {
|
||||
* Wrap the content of the app with the InsightsProvider to track events.
|
||||
*/
|
||||
export function InsightsProvider(props: InsightsProviderProps) {
|
||||
const { enabled, appURL, apiHost, children, ...context } = props;
|
||||
const { enabled, appURL, apiHost, visitorAuthToken, children, ...context } = props;
|
||||
|
||||
const visitorIdRef = React.useRef<string | null>(null);
|
||||
const eventsRef = React.useRef<{
|
||||
@@ -116,6 +116,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
pageContext: eventsForPathname.pageContext,
|
||||
visitorId,
|
||||
sessionId: session.id,
|
||||
visitorAuthToken,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -252,15 +253,16 @@ function transformEvents(input: {
|
||||
pageContext: InsightsEventPageContext;
|
||||
visitorId: string;
|
||||
sessionId: string;
|
||||
visitorAuthToken: string | null;
|
||||
}): api.SiteInsightsEvent[] {
|
||||
const session: api.SiteInsightsEventSession = {
|
||||
sessionId: input.sessionId,
|
||||
visitorId: input.visitorId,
|
||||
userAgent: window.navigator.userAgent,
|
||||
language: window.navigator.language,
|
||||
cookies: getAllBrowserCookiesMap(),
|
||||
cookies: cookies.getAll(),
|
||||
referrer: document.referrer || null,
|
||||
visitorAuthClaims: input.context.visitorAuthClaims,
|
||||
visitorAuthToken: input.visitorAuthToken ?? null,
|
||||
};
|
||||
|
||||
const location: api.SiteInsightsEventLocation = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { getBrowserCookie, setBrowserCookie } from '@/lib/browser-cookies';
|
||||
import * as cookies from '@/lib/cookies';
|
||||
|
||||
const GRANTED_COOKIE = '__gitbook_cookie_granted';
|
||||
|
||||
@@ -8,7 +8,7 @@ const GRANTED_COOKIE = '__gitbook_cookie_granted';
|
||||
* Accept or reject cookies.
|
||||
*/
|
||||
export function setCookiesTracking(enabled: boolean) {
|
||||
setBrowserCookie(GRANTED_COOKIE, enabled ? 'yes' : 'no', {
|
||||
cookies.set(GRANTED_COOKIE, enabled ? 'yes' : 'no', {
|
||||
expires: 365,
|
||||
sameSite: 'none',
|
||||
secure: true,
|
||||
@@ -20,7 +20,7 @@ export function setCookiesTracking(enabled: boolean) {
|
||||
* Return `undefined` if state is not known.
|
||||
*/
|
||||
export function isCookiesTrackingDisabled() {
|
||||
const state = getBrowserCookie(GRANTED_COOKIE);
|
||||
const state = cookies.get(GRANTED_COOKIE);
|
||||
|
||||
if (state === 'yes') {
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { getBrowserCookie } from '@/lib/browser-cookies';
|
||||
import * as cookies from '@/lib/cookies';
|
||||
|
||||
import { isCookiesTrackingDisabled } from './cookies';
|
||||
import { generateRandomId } from './utils';
|
||||
@@ -37,7 +37,7 @@ async function fetchVisitorID(appURL: string): Promise<string> {
|
||||
return generateRandomId();
|
||||
}
|
||||
|
||||
const existingTrackingCookie = getBrowserCookie(VISITORID_COOKIE);
|
||||
const existingTrackingCookie = cookies.get(VISITORID_COOKIE);
|
||||
|
||||
if (existingTrackingCookie) {
|
||||
// If the cookie already exists, we'll just use that. Avoids a server request.
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import type { GitBookSiteContext, GitBookSpaceContext } from '@v2/lib/context';
|
||||
import { getPageDocument } from '@v2/lib/data';
|
||||
import type { GitBookLinker } from '@v2/lib/links';
|
||||
import type { GitBookSpaceLinker } from '@v2/lib/links';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
@@ -67,7 +67,7 @@ export async function PDFPage(props: {
|
||||
);
|
||||
|
||||
// Build a linker that create anchor links for the pages rendered in the PDF page.
|
||||
const linker: GitBookLinker = {
|
||||
const linker: GitBookSpaceLinker = {
|
||||
...baseContext.linker,
|
||||
toPathForPage(input) {
|
||||
if (pages.some((p) => p.page.id === input.page.id)) {
|
||||
@@ -93,7 +93,7 @@ export async function PDFPage(props: {
|
||||
<div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden', 'z-50')}>
|
||||
<a
|
||||
title={tString(language, 'pdf_goback')}
|
||||
href={pdfParams.back ?? linker.toAbsoluteURL(linker.toPathInSpace(''))}
|
||||
href={pdfParams.back ?? linker.toAbsoluteURL(linker.toPathInContent(''))}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
|
||||
@@ -35,7 +35,7 @@ export function PageAside(props: {
|
||||
const { customization, site, space } = context;
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
const pdfHref = context.linker.toPathInSpace(
|
||||
const pdfHref = context.linker.toPathInContent(
|
||||
`~gitbook/pdf?${getPDFURLSearchParams({
|
||||
page: page.id,
|
||||
only: true,
|
||||
|
||||
@@ -70,6 +70,9 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
|
||||
href={`#${section.id}`}
|
||||
className={tcls(
|
||||
'relative',
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-baseline',
|
||||
'z-10',
|
||||
'text-sm',
|
||||
|
||||
@@ -123,7 +126,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
|
||||
>
|
||||
{section.tag ? (
|
||||
<span
|
||||
className={`-mt-0.5 openapi-method openapi-method-${section.tag.toLowerCase()}`}
|
||||
className={`openapi-method openapi-method-${section.tag.toLowerCase()}`}
|
||||
>
|
||||
{section.tag}
|
||||
</span>
|
||||
|
||||
@@ -35,19 +35,25 @@ export function PageBody(props: {
|
||||
return (
|
||||
<>
|
||||
<main
|
||||
className={tcls(
|
||||
'relative min-w-0 flex-1',
|
||||
'py-8 lg:px-12',
|
||||
// Allow words to break if they are too long.
|
||||
'break-anywhere',
|
||||
// When in api page mode without the aside, we align with the border of the main content
|
||||
'page-api-block:xl:max-2xl:pr-0',
|
||||
// Max size to ensure one column in api is aligned with rest of content (2 x 3xl) + (gap-3 + 2) * px-12
|
||||
'page-api-block:mx-auto page-api-block:max-w-screen-2xl',
|
||||
// page.layout.tableOfContents ? null : 'xl:ml-56',
|
||||
asFullWidth ? 'page-full-width' : 'page-default-width',
|
||||
page.layout.tableOfContents ? 'page-has-toc' : 'page-no-toc'
|
||||
)}
|
||||
className={
|
||||
tcls(
|
||||
'flex-1',
|
||||
'relative',
|
||||
'py-8',
|
||||
'lg:px-12',
|
||||
// Allow words to break if they are too long.
|
||||
'break-anywhere',
|
||||
// When in api page mode without the aside, we align with the border of the main content
|
||||
'page-api-block:xl:max-2xl:pr-0',
|
||||
// Max size to ensure one column in api is aligned with rest of content (2 x 3xl) + (gap-3 + 2) * px-12
|
||||
'page-api-block:max-w-screen-2xl',
|
||||
'page-api-block:mx-auto'
|
||||
|
||||
// page.layout.tableOfContents ? null : 'xl:ml-56'
|
||||
) +
|
||||
(asFullWidth ? ' page-full-width' : ' page-default-width') +
|
||||
(!page.layout.tableOfContents ? ' page-no-toc' : ' page-has-toc')
|
||||
}
|
||||
>
|
||||
<PreservePageLayout asFullWidth={asFullWidth} />
|
||||
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
|
||||
@@ -60,14 +66,14 @@ export function PageBody(props: {
|
||||
fallback={
|
||||
<DocumentViewSkeleton
|
||||
document={document}
|
||||
blockStyle="page-api-block:ml-0"
|
||||
blockStyle={['page-api-block:ml-0']}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DocumentView
|
||||
document={document}
|
||||
style="grid [&>*+*]:mt-5"
|
||||
blockStyle="page-api-block:ml-0"
|
||||
style={['[&>*+*]:mt-5', '[&>*:is(ul,ol)]:mt-2', 'grid']}
|
||||
blockStyle={['page-api-block:ml-0']}
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: context,
|
||||
@@ -82,9 +88,23 @@ export function PageBody(props: {
|
||||
<PageFooterNavigation context={context} page={page} />
|
||||
) : null}
|
||||
|
||||
<div className="mx-auto mt-6 page-api-block:ml-0 flex max-w-3xl flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong">
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'flex-wrap',
|
||||
'gap-4',
|
||||
'items-center',
|
||||
'mt-6',
|
||||
'max-w-3xl',
|
||||
'mx-auto',
|
||||
'page-api-block:ml-0',
|
||||
'text-tint',
|
||||
'contrast-more:text-tint-strong'
|
||||
)}
|
||||
>
|
||||
{updatedAt ? (
|
||||
<p className="mr-auto text-sm">
|
||||
<p className={tcls('mr-auto text-sm')}>
|
||||
{t(language, 'page_last_modified', <DateRelative value={updatedAt} />)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -21,10 +21,8 @@ import {
|
||||
hexToRgb,
|
||||
} from '@gitbook/colors';
|
||||
import { IconStyle, IconsProvider } from '@gitbook/icons';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
|
||||
import { getFontData } from '@/fonts';
|
||||
import { fontNotoColorEmoji, ibmPlexMono } from '@/fonts/default';
|
||||
import { fontNotoColorEmoji, fonts, ibmPlexMono } from '@/fonts';
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { getAssetURL } from '@/lib/assets';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -33,7 +31,7 @@ import { ClientContexts } from './ClientContexts';
|
||||
|
||||
import '@gitbook/icons/style.css';
|
||||
import './globals.css';
|
||||
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@v2/lib/env';
|
||||
import { GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@v2/lib/env';
|
||||
|
||||
/**
|
||||
* Layout shared between the content and the PDF renderer.
|
||||
@@ -50,22 +48,6 @@ export async function CustomizationRootLayout(props: {
|
||||
const mixColor = getTintMixColor(customization.styling.primaryColor, tintColor);
|
||||
const sidebarStyles = getSidebarStyles(customization);
|
||||
const { infoColor, successColor, warningColor, dangerColor } = getSemanticColors(customization);
|
||||
const fontData = getFontData(customization.styling.font);
|
||||
|
||||
// Preconnect and preload custom fonts if needed
|
||||
if (fontData.type === 'custom') {
|
||||
ReactDOM.preconnect(GITBOOK_FONTS_URL);
|
||||
fontData.preloadSources
|
||||
.flatMap((face) => face.sources)
|
||||
.forEach(({ url, format }) => {
|
||||
ReactDOM.preload(url, {
|
||||
as: 'font',
|
||||
crossOrigin: 'anonymous',
|
||||
fetchPriority: 'high',
|
||||
type: format ? `font/${format}` : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<html
|
||||
@@ -84,18 +66,14 @@ export async function CustomizationRootLayout(props: {
|
||||
sidebarStyles.list && `sidebar-list-${sidebarStyles.list}`,
|
||||
'links' in customization.styling && `links-${customization.styling.links}`,
|
||||
fontNotoColorEmoji.variable,
|
||||
ibmPlexMono.variable,
|
||||
fontData.type === 'default' ? fontData.variable : 'font-custom'
|
||||
fonts[customization.styling.font].variable,
|
||||
ibmPlexMono.variable
|
||||
)}
|
||||
>
|
||||
<head>
|
||||
{customization.privacyPolicy.url ? (
|
||||
<link rel="privacy-policy" href={customization.privacyPolicy.url} />
|
||||
) : null}
|
||||
|
||||
{/* Inject custom font @font-face rules */}
|
||||
{fontData.type === 'custom' ? <style>{fontData.fontFaceRules}</style> : null}
|
||||
|
||||
<style
|
||||
nonce={
|
||||
//Since I can't get the nonce to work for inline styles, we need to allow unsafe-inline
|
||||
|
||||
@@ -236,6 +236,7 @@ function AnswerSources(props: {
|
||||
'focus-within:text-primary'
|
||||
)}
|
||||
href={source.href}
|
||||
prefetch={false}
|
||||
>
|
||||
<Icon
|
||||
icon="arrow-up-right"
|
||||
|
||||
@@ -99,7 +99,7 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
|
||||
);
|
||||
}
|
||||
|
||||
function Shortcut() {
|
||||
const Shortcut = () => {
|
||||
const [operatingSystem, setOperatingSystem] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -115,14 +115,21 @@ function Shortcut() {
|
||||
setOperatingSystem(getOperatingSystem());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
return operatingSystem ? (
|
||||
<div
|
||||
aria-busy={operatingSystem === null ? 'true' : undefined}
|
||||
className={tcls(
|
||||
`shortcut -mr-1 hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt",_"case"] contrast-more:text-tint-strong md:flex`,
|
||||
operatingSystem
|
||||
? 'motion-safe:animate-fadeIn motion-reduce:opacity-100'
|
||||
: 'opacity-0'
|
||||
'shortcut',
|
||||
'hidden',
|
||||
'md:flex',
|
||||
'gap-0.5',
|
||||
'-mr-1',
|
||||
'justify-end',
|
||||
'text-xs',
|
||||
'text-tint',
|
||||
'contrast-more:text-tint-strong',
|
||||
'whitespace-nowrap',
|
||||
`[font-feature-settings:"calt",_"case"]`,
|
||||
'animate-fadeIn'
|
||||
)}
|
||||
>
|
||||
<kbd
|
||||
@@ -134,5 +141,5 @@ function Shortcut() {
|
||||
K
|
||||
</kbd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
) : null;
|
||||
};
|
||||
|
||||
@@ -189,9 +189,6 @@ function SearchModalBody(
|
||||
setSearchState((state) => (state ? { ...state, ask: true } : null));
|
||||
};
|
||||
|
||||
// We trim the query to avoid invalidating the search when the user is typing between words.
|
||||
const normalizedQuery = state.query.trim();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
transition={{
|
||||
@@ -291,14 +288,12 @@ function SearchModalBody(
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
global={isMultiVariants && state.global}
|
||||
query={normalizedQuery}
|
||||
query={state.query}
|
||||
withAsk={withAsk}
|
||||
onSwitchToAsk={onSwitchToAsk}
|
||||
/>
|
||||
) : null}
|
||||
{normalizedQuery && state.ask && withAsk ? (
|
||||
<SearchAskAnswer query={normalizedQuery} />
|
||||
) : null}
|
||||
{state.query && state.ask && withAsk ? <SearchAskAnswer query={state.query} /> : null}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,8 +77,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
// Silently fetch the recommended questions, instead of showing a spinner
|
||||
setResultsState({ results: [], fetching: false });
|
||||
setResultsState({ results: [], fetching: true });
|
||||
|
||||
// We currently have a bug where the same question can be returned multiple times.
|
||||
// This is a workaround to avoid that.
|
||||
|
||||
@@ -12,7 +12,6 @@ import { SpaceLayout } from '@/components/SpaceLayout';
|
||||
import { buildVersion } from '@/lib/build';
|
||||
import { isSiteIndexable } from '@/lib/seo';
|
||||
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@v2/lib/env';
|
||||
import { getResizedImageURL } from '@v2/lib/images';
|
||||
import { ClientContexts } from './ClientContexts';
|
||||
@@ -26,10 +25,10 @@ export async function SiteLayout(props: {
|
||||
context: GitBookSiteContext;
|
||||
forcedTheme?: CustomizationThemeMode | null;
|
||||
withTracking: boolean;
|
||||
visitorAuthClaims: VisitorAuthClaims;
|
||||
visitorAuthToken: string | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { context, nonce, forcedTheme, withTracking, visitorAuthClaims, children } = props;
|
||||
const { context, nonce, forcedTheme, withTracking, visitorAuthToken, children } = props;
|
||||
|
||||
const { scripts, customization } = context;
|
||||
|
||||
@@ -58,7 +57,7 @@ export async function SiteLayout(props: {
|
||||
<SpaceLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
visitorAuthToken={visitorAuthToken}
|
||||
>
|
||||
{children}
|
||||
</SpaceLayout>
|
||||
@@ -110,7 +109,7 @@ export async function generateSiteLayoutMetadata(context: GitBookSiteContext): P
|
||||
height: faviconSize,
|
||||
})
|
||||
: linker.toAbsoluteURL(
|
||||
linker.toPathInSpace('~gitbook/icon?size=small&theme=light')
|
||||
linker.toPathInContent('~gitbook/icon?size=small&theme=light')
|
||||
),
|
||||
type: 'image/png',
|
||||
media: '(prefers-color-scheme: light)',
|
||||
@@ -121,7 +120,9 @@ export async function generateSiteLayoutMetadata(context: GitBookSiteContext): P
|
||||
width: faviconSize,
|
||||
height: faviconSize,
|
||||
})
|
||||
: linker.toAbsoluteURL(linker.toPathInSpace('~gitbook/icon?size=small&theme=dark')),
|
||||
: linker.toAbsoluteURL(
|
||||
linker.toPathInContent('~gitbook/icon?size=small&theme=dark')
|
||||
),
|
||||
type: 'image/png',
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
|
||||
|
||||
import { getResizedImageURL } from '@v2/lib/images';
|
||||
import { PageClientLayout } from './PageClientLayout';
|
||||
import { type PagePathParams, fetchPageData, getPathnameParam } from './fetch';
|
||||
import { type PagePathParams, fetchPageData, getPathnameParam, normalizePathname } from './fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -33,11 +33,11 @@ export async function SitePage(props: SitePageProps) {
|
||||
|
||||
const rawPathname = getPathnameParam(props.pageParams);
|
||||
if (!pageTarget) {
|
||||
const pathname = rawPathname.toLowerCase();
|
||||
const pathname = normalizePathname(rawPathname);
|
||||
if (pathname !== rawPathname) {
|
||||
// If the pathname was not normalized, redirect to the normalized version
|
||||
// before trying to resolve the page again
|
||||
redirect(context.linker.toPathInSpace(pathname));
|
||||
redirect(context.linker.toPathInContent(pathname));
|
||||
} else {
|
||||
notFound();
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
|
||||
width: 1200,
|
||||
height: 630,
|
||||
})
|
||||
: linker.toAbsoluteURL(linker.toPathInSpace(`~gitbook/ogimage/${page.id}`)),
|
||||
: linker.toAbsoluteURL(linker.toPathInContent(`~gitbook/ogimage/${page.id}`)),
|
||||
],
|
||||
},
|
||||
robots:
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { GitBookSiteContext } from '@v2/lib/context';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { resolvePageId, resolvePagePath } from '@/lib/pages';
|
||||
import { withLeadingSlash } from '@/lib/paths';
|
||||
import { getDataOrNull } from '@v2/lib/data';
|
||||
|
||||
export interface PagePathParams {
|
||||
@@ -36,14 +35,14 @@ export async function fetchPageData(context: GitBookSiteContext, params: PagePar
|
||||
* If the path can't be found, we try to resolve it from the API to handle redirects.
|
||||
*/
|
||||
async function resolvePage(context: GitBookSiteContext, params: PagePathParams | PageIdParams) {
|
||||
const { organizationId, site, space, revisionId, pages, shareKey, linker } = context;
|
||||
const { organizationId, site, space, revisionId, pages, shareKey } = context;
|
||||
|
||||
if ('pageId' in params) {
|
||||
return resolvePageId(pages, params.pageId);
|
||||
}
|
||||
|
||||
const rawPathname = getPathnameParam(params);
|
||||
const pathname = rawPathname.toLowerCase();
|
||||
const pathname = normalizePathname(rawPathname);
|
||||
|
||||
// When resolving a page, we use the lowercased pathname
|
||||
const page = resolvePagePath(pages, pathname);
|
||||
@@ -68,31 +67,16 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
|
||||
}
|
||||
|
||||
// If a page still can't be found, we try with the API, in case we have a redirect at site level.
|
||||
const redirectPathname = withLeadingSlash(rawPathname);
|
||||
if (/^\/[a-zA-Z0-9-_.\/]+[a-zA-Z0-9-_.]$/.test(redirectPathname)) {
|
||||
const redirectSources = new Set<string>([
|
||||
// Test the pathname relative to the root
|
||||
// For example hello/world -> section/variant/hello/world
|
||||
withLeadingSlash(
|
||||
linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname))
|
||||
),
|
||||
// Test the pathname relative to the content/space
|
||||
// For example hello/world -> /hello/world
|
||||
redirectPathname,
|
||||
]);
|
||||
for (const source of redirectSources) {
|
||||
const resolvedSiteRedirect = await getDataOrNull(
|
||||
context.dataFetcher.getSiteRedirectBySource({
|
||||
organizationId,
|
||||
siteId: site.id,
|
||||
source,
|
||||
siteShareKey: shareKey,
|
||||
})
|
||||
);
|
||||
if (resolvedSiteRedirect) {
|
||||
return redirect(linker.toLinkForContent(resolvedSiteRedirect.target));
|
||||
}
|
||||
}
|
||||
const resolvedSiteRedirect = await getDataOrNull(
|
||||
context.dataFetcher.getSiteRedirectBySource({
|
||||
organizationId,
|
||||
siteId: site.id,
|
||||
source: rawPathname.startsWith('/') ? rawPathname : `/${rawPathname}`,
|
||||
siteShareKey: shareKey,
|
||||
})
|
||||
);
|
||||
if (resolvedSiteRedirect) {
|
||||
return redirect(resolvedSiteRedirect.target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,3 +99,10 @@ export function getPathnameParam(params: PagePathParams): string {
|
||||
|
||||
return pathname.map((part) => decodeURIComponent(part)).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the URL pathname into the format used in the revision page path.
|
||||
*/
|
||||
export function normalizePathname(pathname: string) {
|
||||
return pathname.toLowerCase();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { getSpaceLanguage } from '@/intl/server';
|
||||
import { t } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@v2/lib/env';
|
||||
import { SpacesDropdown } from '../Header/SpacesDropdown';
|
||||
import { InsightsProvider } from '../Insights';
|
||||
@@ -27,13 +26,13 @@ export function SpaceLayout(props: {
|
||||
/** Whether to enable tracking of events into site insights. */
|
||||
withTracking: boolean;
|
||||
|
||||
/** The visitor auth claims. */
|
||||
visitorAuthClaims: VisitorAuthClaims;
|
||||
/** The visitor token used to access this content */
|
||||
visitorAuthToken: string | null;
|
||||
|
||||
/** The children of the layout. */
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { context, withTracking, visitorAuthClaims, children } = props;
|
||||
const { context, withTracking, visitorAuthToken, children } = props;
|
||||
const { siteSpace, customization, sections, siteSpaces } = context;
|
||||
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
@@ -48,11 +47,12 @@ export function SpaceLayout(props: {
|
||||
customization.footer.groups?.length;
|
||||
|
||||
return (
|
||||
<SpaceLayoutContextProvider basePath={context.linker.toPathInSpace('')}>
|
||||
<SpaceLayoutContextProvider basePath={context.linker.toPathInContent('')}>
|
||||
<InsightsProvider
|
||||
enabled={withTracking}
|
||||
appURL={GITBOOK_APP_URL}
|
||||
apiHost={GITBOOK_API_PUBLIC_URL}
|
||||
visitorAuthToken={visitorAuthToken}
|
||||
organizationId={context.organizationId}
|
||||
siteId={context.site.id}
|
||||
siteSectionId={context.sections?.current?.id ?? null}
|
||||
@@ -60,7 +60,6 @@ export function SpaceLayout(props: {
|
||||
siteShareKey={context.shareKey ?? null}
|
||||
revisionId={context.revisionId}
|
||||
spaceId={context.space.id}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
<Header withTopHeader={withTopHeader} context={context} />
|
||||
<div className="scroll-nojump">
|
||||
@@ -136,7 +135,7 @@ export function SpaceLayout(props: {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
|
||||
<div className={tcls('flex-1', 'flex', 'flex-col')}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,12 +12,11 @@ export function useScrollActiveId(
|
||||
) {
|
||||
const { rootMargin, threshold = 0.5 } = options;
|
||||
|
||||
const [activeId, setActiveId] = React.useState<string>(ids[0]);
|
||||
const [activeId, setActiveId] = React.useState<string | null>(null);
|
||||
const sectionsIntersectingMap = React.useRef<Map<string, boolean>>(new Map());
|
||||
|
||||
React.useEffect(() => {
|
||||
const defaultActiveId = ids[0];
|
||||
setActiveId((activeId) => (ids.indexOf(activeId) !== -1 ? activeId : defaultActiveId));
|
||||
setActiveId(null);
|
||||
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
return;
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { CustomizationFontDefinition } from '@gitbook/api';
|
||||
import stylelint from 'stylelint';
|
||||
import { generateFontFacesCSS, getFontSourcesToPreload } from './custom';
|
||||
|
||||
const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
|
||||
basic: {
|
||||
id: 'open-sans',
|
||||
fontFamily: 'Open Sans',
|
||||
fontFaces: [
|
||||
{
|
||||
weight: 400,
|
||||
sources: [
|
||||
{
|
||||
url: 'https://example.com/fonts/opensans-regular.woff2',
|
||||
format: 'woff2',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
weight: 700,
|
||||
sources: [
|
||||
{
|
||||
url: 'https://example.com/fonts/opensans-bold.woff2',
|
||||
format: 'woff2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
multiWeight: {
|
||||
id: 'roboto',
|
||||
fontFamily: 'Roboto',
|
||||
fontFaces: [
|
||||
{
|
||||
weight: 300,
|
||||
sources: [
|
||||
{
|
||||
url: 'https://example.com/fonts/roboto-light.woff2',
|
||||
format: 'woff2',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
weight: 400,
|
||||
sources: [
|
||||
{
|
||||
url: 'https://example.com/fonts/roboto-regular.woff2',
|
||||
format: 'woff2',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
weight: 500,
|
||||
sources: [
|
||||
{
|
||||
url: 'https://example.com/fonts/roboto-medium.woff2',
|
||||
format: 'woff2',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
weight: 700,
|
||||
sources: [
|
||||
{
|
||||
url: 'https://example.com/fonts/roboto-bold.woff2',
|
||||
format: 'woff2',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
weight: 900,
|
||||
sources: [
|
||||
{
|
||||
url: 'https://example.com/fonts/roboto-black.woff2',
|
||||
format: 'woff2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
multiSource: {
|
||||
id: 'lato',
|
||||
fontFamily: 'Lato',
|
||||
fontFaces: [
|
||||
{
|
||||
weight: 400,
|
||||
sources: [
|
||||
{
|
||||
url: 'https://example.com/fonts/lato-regular.woff2',
|
||||
format: 'woff2',
|
||||
},
|
||||
{ url: 'https://example.com/fonts/lato-regular.woff', format: 'woff' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
missingFormat: {
|
||||
id: 'source-sans',
|
||||
fontFamily: 'Source Sans Pro',
|
||||
fontFaces: [
|
||||
{
|
||||
weight: 400,
|
||||
sources: [
|
||||
{ url: 'https://example.com/fonts/sourcesans-regular.woff2' },
|
||||
{
|
||||
url: 'https://example.com/fonts/sourcesans-regular.woff',
|
||||
format: 'woff',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
empty: {
|
||||
id: 'empty-font',
|
||||
fontFamily: 'Empty Font',
|
||||
fontFaces: [],
|
||||
},
|
||||
|
||||
specialChars: {
|
||||
id: 'special-font',
|
||||
fontFamily: 'Special Font & Co.',
|
||||
fontFaces: [
|
||||
{
|
||||
weight: 400,
|
||||
sources: [{ url: 'https://example.com/fonts/special.woff2', format: 'woff2' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
complex: {
|
||||
id: 'complex-font',
|
||||
fontFamily: 'Complex Font',
|
||||
fontFaces: [
|
||||
{
|
||||
weight: 400,
|
||||
sources: [
|
||||
{ url: 'https://example.com/fonts/regular.woff2' },
|
||||
{ url: 'https://example.com/fonts/regular.woff' },
|
||||
],
|
||||
},
|
||||
{
|
||||
weight: 700,
|
||||
sources: [
|
||||
{ url: 'https://example.com/fonts/bold.woff2' },
|
||||
{ url: 'https://example.com/fonts/bold.woff' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
variousURLs: {
|
||||
id: 'various-urls',
|
||||
fontFamily: 'Various URLs Font',
|
||||
fontFaces: [
|
||||
{
|
||||
weight: 400,
|
||||
sources: [
|
||||
{ url: 'https://example.com/fonts.woff2' },
|
||||
{ url: 'https://example.com/fonts.woff' },
|
||||
{ url: 'https://example.com/fonts.woff2' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Helper function to validate CSS with stylelint
|
||||
async function isCSSValid(css: string): Promise<boolean> {
|
||||
try {
|
||||
const stylelintResult = await stylelint.lint({
|
||||
code: css,
|
||||
config: {
|
||||
rules: {
|
||||
'at-rule-no-unknown': true,
|
||||
'declaration-block-no-duplicate-properties': true,
|
||||
'property-no-unknown': true,
|
||||
'selector-pseudo-class-no-unknown': true,
|
||||
'selector-pseudo-element-no-unknown': true,
|
||||
'no-duplicate-selectors': true,
|
||||
'no-empty-source': true,
|
||||
'no-invalid-double-slash-comments': true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return !stylelintResult.errored;
|
||||
} catch (error) {
|
||||
console.error('Error while linting CSS:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('generateFontFacesCSS', () => {
|
||||
test('basic case with regular and bold weights', async () => {
|
||||
const css = generateFontFacesCSS(TEST_FONTS.basic);
|
||||
|
||||
const isValid = await isCSSValid(css);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
expect(css).toContain('font-weight: 400');
|
||||
expect(css).toContain('font-weight: 700');
|
||||
expect(css).toContain(
|
||||
"url(https://example.com/fonts/opensans-regular.woff2) format('woff2')"
|
||||
);
|
||||
expect(css).toContain("url(https://example.com/fonts/opensans-bold.woff2) format('woff2')");
|
||||
expect(css).toContain('--font-custom: CustomFont');
|
||||
});
|
||||
|
||||
test('multiple font weights', async () => {
|
||||
const css = generateFontFacesCSS(TEST_FONTS.multiWeight);
|
||||
|
||||
const isValid = await isCSSValid(css);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
[300, 400, 500, 700, 900].forEach((weight) => {
|
||||
expect(css).toContain(`font-weight: ${weight}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('multiple sources for a single weight', async () => {
|
||||
const css = generateFontFacesCSS(TEST_FONTS.multiSource);
|
||||
|
||||
const isValid = await isCSSValid(css);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
expect(css).toContain(
|
||||
"url(https://example.com/fonts/lato-regular.woff2) format('woff2'), url(https://example.com/fonts/lato-regular.woff) format('woff')"
|
||||
);
|
||||
});
|
||||
|
||||
test('missing format property', async () => {
|
||||
const css = generateFontFacesCSS(TEST_FONTS.missingFormat);
|
||||
|
||||
const isValid = await isCSSValid(css);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
expect(css).toContain(
|
||||
"url(https://example.com/fonts/sourcesans-regular.woff2), url(https://example.com/fonts/sourcesans-regular.woff) format('woff')"
|
||||
);
|
||||
});
|
||||
|
||||
test('empty font faces array', async () => {
|
||||
const css = generateFontFacesCSS(TEST_FONTS.empty);
|
||||
|
||||
expect(css).toBe('');
|
||||
});
|
||||
|
||||
test('font with special characters in name', async () => {
|
||||
const css = generateFontFacesCSS(TEST_FONTS.specialChars);
|
||||
|
||||
// Validate CSS syntax
|
||||
const isValid = await isCSSValid(css);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFontSourcesToPreload', () => {
|
||||
const preloadTestCases = [
|
||||
{ name: 'basic case', font: TEST_FONTS.basic, expectedCount: 2 },
|
||||
{ name: 'multiple weights', font: TEST_FONTS.multiWeight, expectedCount: 2 },
|
||||
{ name: 'multiple sources', font: TEST_FONTS.multiSource, expectedCount: 2 },
|
||||
{ name: 'missing format', font: TEST_FONTS.missingFormat, expectedCount: 2 },
|
||||
{ name: 'empty font faces', font: TEST_FONTS.empty, expectedCount: 0 },
|
||||
{ name: 'special characters', font: TEST_FONTS.specialChars, expectedCount: 1 },
|
||||
{ name: 'complex case', font: TEST_FONTS.complex, expectedCount: 4 },
|
||||
];
|
||||
|
||||
preloadTestCases.forEach(({ name, font, expectedCount }) => {
|
||||
test(`extracts ${expectedCount} URLs from ${name}`, () => {
|
||||
const result = getFontSourcesToPreload(font).flatMap((face) => face.sources);
|
||||
expect(result).toBeArray();
|
||||
expect(result.length).toBe(expectedCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { CustomizationFontDefinition } from '@gitbook/api';
|
||||
|
||||
/**
|
||||
* Define the custom font faces and set the --font-custom to the custom font name
|
||||
*/
|
||||
export function generateFontFacesCSS(customFont: CustomizationFontDefinition): string {
|
||||
const { fontFaces } = customFont;
|
||||
|
||||
// Generate font face declarations for all weights
|
||||
const fontFaceDeclarations = fontFaces
|
||||
.map((face) => {
|
||||
const srcAttr = face.sources
|
||||
.map((source) => {
|
||||
let srcDefinition = `url(${source.url})`;
|
||||
|
||||
if (source.format) {
|
||||
srcDefinition += ` format('${source.format}')`;
|
||||
}
|
||||
|
||||
return srcDefinition;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
// We could use the customFont.fontFamily name here, but to avoid extra normalization we're using 'CustomFont'
|
||||
return `
|
||||
@font-face {
|
||||
font-family: CustomFont;
|
||||
font-style: normal;
|
||||
font-weight: ${face.weight};
|
||||
font-display: swap;
|
||||
src: ${srcAttr};
|
||||
}
|
||||
`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return fontFaceDeclarations
|
||||
? `${fontFaceDeclarations}
|
||||
:root {
|
||||
--font-custom: CustomFont;
|
||||
}`
|
||||
: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of font sources to preload (only 400 and 700 weights)
|
||||
*/
|
||||
export function getFontSourcesToPreload(customFont: CustomizationFontDefinition) {
|
||||
return customFont.fontFaces.filter(
|
||||
(face): face is typeof face & { weight: 400 | 700 } =>
|
||||
face.weight === 400 || face.weight === 700
|
||||
);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { CustomizationDefaultFont } from '@gitbook/api';
|
||||
import {
|
||||
Fira_Sans_Extra_Condensed,
|
||||
IBM_Plex_Mono,
|
||||
IBM_Plex_Serif,
|
||||
Inter,
|
||||
Lato,
|
||||
Merriweather,
|
||||
Noto_Color_Emoji,
|
||||
Noto_Sans,
|
||||
Open_Sans,
|
||||
Overpass,
|
||||
Poppins,
|
||||
Raleway,
|
||||
Roboto,
|
||||
Roboto_Slab,
|
||||
Source_Sans_3,
|
||||
Ubuntu,
|
||||
} from 'next/font/google';
|
||||
import localFont from 'next/font/local';
|
||||
|
||||
export const fontNotoColorEmoji = Noto_Color_Emoji({
|
||||
variable: '--font-noto-color-emoji',
|
||||
weight: ['400'],
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
/*
|
||||
Fonts are downloaded and loaded by next/font.
|
||||
|
||||
We can't use "preload: true" as otherwise Next will preload all the fonts on the page
|
||||
while spaces only use one font at a time.
|
||||
*/
|
||||
|
||||
const inter = Inter({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
export const ibmPlexMono = IBM_Plex_Mono({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-mono',
|
||||
style: 'normal',
|
||||
display: 'swap',
|
||||
preload: false,
|
||||
fallback: ['monospace'],
|
||||
adjustFontFallback: false,
|
||||
});
|
||||
|
||||
const firaSans = Fira_Sans_Extra_Condensed({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const ibmPlexSerif = IBM_Plex_Serif({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['serif'],
|
||||
});
|
||||
|
||||
const lato = Lato({
|
||||
weight: ['400', '700', '900'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const merriweather = Merriweather({
|
||||
weight: ['400', '700', '900'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['serif'],
|
||||
});
|
||||
|
||||
const notoSans = Noto_Sans({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const openSans = Open_Sans({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const overpass = Overpass({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const poppins = Poppins({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const raleway = Raleway({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const roboto = Roboto({
|
||||
weight: ['400', '500', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const robotoSlab = Roboto_Slab({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const sourceSansPro = Source_Sans_3({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const ubuntu = Ubuntu({
|
||||
weight: ['400', '500', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const abcFavorit = localFont({
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
src: [
|
||||
{
|
||||
path: './ABCFavorit/ABCFavorit-Variable.woff2',
|
||||
weight: '400 700',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './ABCFavorit/ABCFavorit-BoldItalic.woff2',
|
||||
weight: '700',
|
||||
style: 'italic',
|
||||
},
|
||||
{
|
||||
path: './ABCFavorit/ABCFavorit-MediumItalic.woff2',
|
||||
weight: '500',
|
||||
style: 'italic',
|
||||
},
|
||||
{
|
||||
path: './ABCFavorit/ABCFavorit-RegularItalic.woff2',
|
||||
weight: '400',
|
||||
style: 'italic',
|
||||
},
|
||||
],
|
||||
declarations: [{ prop: 'ascent-override', value: '100%' }],
|
||||
});
|
||||
|
||||
/**
|
||||
* Font definitions.
|
||||
*/
|
||||
export const fonts: { [fontName in CustomizationDefaultFont]: { variable: string } } = {
|
||||
[CustomizationDefaultFont.Inter]: inter,
|
||||
[CustomizationDefaultFont.FiraSans]: firaSans,
|
||||
[CustomizationDefaultFont.IBMPlexSerif]: ibmPlexSerif,
|
||||
[CustomizationDefaultFont.Lato]: lato,
|
||||
[CustomizationDefaultFont.Merriweather]: merriweather,
|
||||
[CustomizationDefaultFont.NotoSans]: notoSans,
|
||||
[CustomizationDefaultFont.OpenSans]: openSans,
|
||||
[CustomizationDefaultFont.Overpass]: overpass,
|
||||
[CustomizationDefaultFont.Poppins]: poppins,
|
||||
[CustomizationDefaultFont.Raleway]: raleway,
|
||||
[CustomizationDefaultFont.Roboto]: roboto,
|
||||
[CustomizationDefaultFont.RobotoSlab]: robotoSlab,
|
||||
[CustomizationDefaultFont.SourceSansPro]: sourceSansPro,
|
||||
[CustomizationDefaultFont.Ubuntu]: ubuntu,
|
||||
[CustomizationDefaultFont.ABCFavorit]: abcFavorit,
|
||||
};
|
||||
@@ -1,43 +1,207 @@
|
||||
import type { CustomizationFont, CustomizationFontDefinition } from '@gitbook/api';
|
||||
import { generateFontFacesCSS, getFontSourcesToPreload } from './custom';
|
||||
import { fonts } from './default';
|
||||
import { CustomizationDefaultFont } from '@gitbook/api';
|
||||
import {
|
||||
Fira_Sans_Extra_Condensed,
|
||||
IBM_Plex_Mono,
|
||||
IBM_Plex_Serif,
|
||||
Inter,
|
||||
Lato,
|
||||
Merriweather,
|
||||
Noto_Color_Emoji,
|
||||
Noto_Sans,
|
||||
Open_Sans,
|
||||
Overpass,
|
||||
Poppins,
|
||||
Raleway,
|
||||
Roboto,
|
||||
Roboto_Slab,
|
||||
Source_Sans_3,
|
||||
Ubuntu,
|
||||
} from 'next/font/google';
|
||||
import localFont from 'next/font/local';
|
||||
|
||||
export const fontNotoColorEmoji = Noto_Color_Emoji({
|
||||
variable: '--font-noto-color-emoji',
|
||||
weight: ['400'],
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
/*
|
||||
Fonts are downloaded and loaded by next/font.
|
||||
|
||||
We can't use "preload: true" as otherwise Next will preload all the fonts on the page
|
||||
while spaces only use one font at a time.
|
||||
*/
|
||||
|
||||
const inter = Inter({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
export const ibmPlexMono = IBM_Plex_Mono({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-mono',
|
||||
style: 'normal',
|
||||
display: 'swap',
|
||||
preload: false,
|
||||
fallback: ['monospace'],
|
||||
adjustFontFallback: false,
|
||||
});
|
||||
|
||||
const firaSans = Fira_Sans_Extra_Condensed({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const ibmPlexSerif = IBM_Plex_Serif({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['serif'],
|
||||
});
|
||||
|
||||
const lato = Lato({
|
||||
weight: ['400', '700', '900'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const merriweather = Merriweather({
|
||||
weight: ['400', '700', '900'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['serif'],
|
||||
});
|
||||
|
||||
const notoSans = Noto_Sans({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const openSans = Open_Sans({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const overpass = Overpass({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const poppins = Poppins({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const raleway = Raleway({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const roboto = Roboto({
|
||||
weight: ['400', '500', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const robotoSlab = Roboto_Slab({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const sourceSansPro = Source_Sans_3({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const ubuntu = Ubuntu({
|
||||
weight: ['400', '500', '700'],
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
});
|
||||
|
||||
const abcFavorit = localFont({
|
||||
variable: '--font-content',
|
||||
preload: false,
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'arial'],
|
||||
src: [
|
||||
{
|
||||
path: './ABCFavorit/ABCFavorit-Variable.woff2',
|
||||
weight: '400 700',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './ABCFavorit/ABCFavorit-BoldItalic.woff2',
|
||||
weight: '700',
|
||||
style: 'italic',
|
||||
},
|
||||
{
|
||||
path: './ABCFavorit/ABCFavorit-MediumItalic.woff2',
|
||||
weight: '500',
|
||||
style: 'italic',
|
||||
},
|
||||
{
|
||||
path: './ABCFavorit/ABCFavorit-RegularItalic.woff2',
|
||||
weight: '400',
|
||||
style: 'italic',
|
||||
},
|
||||
],
|
||||
declarations: [{ prop: 'ascent-override', value: '100%' }],
|
||||
});
|
||||
|
||||
/**
|
||||
* Represents font data for either a default font or a custom font
|
||||
* Font definitions.
|
||||
*/
|
||||
type FontData = DefaultFontData | CustomFontData;
|
||||
|
||||
/**
|
||||
* Font data for a default font, currently handle with next/font
|
||||
*/
|
||||
interface DefaultFontData {
|
||||
type: 'default';
|
||||
variable: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font data for a custom font with @font-face rules
|
||||
*/
|
||||
interface CustomFontData {
|
||||
type: 'custom';
|
||||
fontFaceRules: string;
|
||||
preloadSources: CustomizationFontDefinition['fontFaces'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate font data for a given font configuration
|
||||
*/
|
||||
export function getFontData(font: CustomizationFont): FontData {
|
||||
if (typeof font === 'string') {
|
||||
return {
|
||||
type: 'default',
|
||||
variable: fonts[font].variable,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'custom',
|
||||
fontFaceRules: generateFontFacesCSS(font),
|
||||
preloadSources: getFontSourcesToPreload(font),
|
||||
};
|
||||
}
|
||||
export const fonts: { [fontName in CustomizationDefaultFont]: { variable: string } } = {
|
||||
[CustomizationDefaultFont.Inter]: inter,
|
||||
[CustomizationDefaultFont.FiraSans]: firaSans,
|
||||
[CustomizationDefaultFont.IBMPlexSerif]: ibmPlexSerif,
|
||||
[CustomizationDefaultFont.Lato]: lato,
|
||||
[CustomizationDefaultFont.Merriweather]: merriweather,
|
||||
[CustomizationDefaultFont.NotoSans]: notoSans,
|
||||
[CustomizationDefaultFont.OpenSans]: openSans,
|
||||
[CustomizationDefaultFont.Overpass]: overpass,
|
||||
[CustomizationDefaultFont.Poppins]: poppins,
|
||||
[CustomizationDefaultFont.Raleway]: raleway,
|
||||
[CustomizationDefaultFont.Roboto]: roboto,
|
||||
[CustomizationDefaultFont.RobotoSlab]: robotoSlab,
|
||||
[CustomizationDefaultFont.SourceSansPro]: sourceSansPro,
|
||||
[CustomizationDefaultFont.Ubuntu]: ubuntu,
|
||||
[CustomizationDefaultFont.ABCFavorit]: abcFavorit,
|
||||
};
|
||||
|
||||
@@ -89,14 +89,14 @@ A serialnumbers value governs its order within the given token.id</p>
|
||||
<td>lt(e)</td>
|
||||
<td>Y</td>
|
||||
<td>Single occurrence only. Requires the presence of an <strong>lte</strong> or <strong>eq</strong> <strong>token.id</strong> query</td>
|
||||
<td>?token.id=lte:X&serialnumber=lt:Y</td>
|
||||
<td>?token.id=lte:X&serialnumber=lt:Y</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>gt(e)</td>
|
||||
<td>Y</td>
|
||||
<td>Single occurrence only. Requires the presence of an <strong>gte</strong> or <strong>eq</strong> <strong>token.id</strong> query</td>
|
||||
<td>?token.id=gte:X&serialnumber=gt:Y</td>
|
||||
<td>?token.id=gte:X&serialnumber=gt:Y</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>spender.id</td>
|
||||
@@ -131,16 +131,3 @@ A serialnumbers value governs its order within the given token.id</p>
|
||||
<p>Note: When searching across a range for individual NFTs a <strong>serialnumber</strong> with an additional <strong>token.id</strong> query filter must be provided.
|
||||
Both filters must be a single occurrence of <strong>gt(e)</strong> or <strong>lt(e)</strong> which provide a lower and or upper boundary for search.</p>"
|
||||
`;
|
||||
|
||||
exports[`parseMarkdown should parse html 1`] = `
|
||||
"<p>Returns trending events for the given time frame.</p>
|
||||
<p>The events are sorted by <code>trending score</code>.</p>
|
||||
<p>To calculate trending score for an event, each bet placed on this event
|
||||
contributes a value determined by how recently the bet was placed.</p>
|
||||
<p>Recent bets add more value than older ones.</p>
|
||||
<p>The normalized value of its score is also assigned to each event as its confidence.</p>
|
||||
<p>In this example we get all trending events where <code>league</code> is <code>UEFA Champions League</code>.</p>
|
||||
<pre><code class="language-bash">$ curl --request GET \\
|
||||
--url 'https://api.vaix.ai/api/sports/events/trending?filters=league%3Aeq%3AUEFA%20Champions%20League'
|
||||
</code></pre>"
|
||||
`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user