mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-15 15:15:55 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee26206cdd | |||
| cf74d00758 | |||
| c2256bfd14 | |||
| 5d73a0f062 | |||
| 8f5f6c61f8 | |||
| 9a86965ba1 | |||
| c8d5f825fc | |||
| 5db3cbc2ce | |||
| f8d148bec4 | |||
| 198dd9f158 | |||
| 2b63bc158a | |||
| 4a8097d968 |
@@ -1,5 +1,9 @@
|
||||
on: [push]
|
||||
name: CI
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
|
||||
@@ -75,7 +75,7 @@ GitBook's rendering engine is fully open-source and built on top of [Next.js](ht
|
||||
|
||||
### Types of contributions
|
||||
|
||||
We encourage you to contribute to GitBook to help us build the best tool for doucmenting techincal knowledge. If you're looking for some quick ways to contribute, continue reading to learn more about popular contributions.
|
||||
We encourage you to contribute to GitBook to help us build the best tool for documenting technical knowledge. If you're looking for some quick ways to contribute, continue reading to learn more about popular contributions.
|
||||
|
||||
#### Translations
|
||||
|
||||
@@ -92,7 +92,7 @@ Encounter a bug or find an issue you'd like to fix? Helping us fix issues relate
|
||||
>
|
||||
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](/.github/CONTRIBUTING.md) to get started._
|
||||
>
|
||||
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintainance, or updates to forked and self-hosted instances of this project.
|
||||
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project.
|
||||
>
|
||||
> We want to make it as easy as possible for our community to collaborate and push the future of GitBook, which is why we encourage you to contribute to our product directly instead of creating your own version.
|
||||
|
||||
|
||||
@@ -39,6 +39,15 @@ module.exports = withSentryConfig(
|
||||
|
||||
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
|
||||
poweredByHeader: false,
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.gitbook.io',
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
silent: true,
|
||||
|
||||
+2
-2
@@ -20,10 +20,10 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@geist-ui/icons": "^1.0.2",
|
||||
"@gitbook/api": "^0.43.0",
|
||||
"@gitbook/api": "^0.46.0",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@sentry/nextjs": "^7.94.1",
|
||||
"@sentry/nextjs": "^8.0.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@upstash/redis": "^1.27.1",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"exports": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"classnames": "^2.5.1",
|
||||
"@gitbook/api": "^0.41.0",
|
||||
"@gitbook/api": "^0.46.0",
|
||||
"assert-never": "^1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "@gitbook/react-openapi",
|
||||
"exports": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@scalar/api-client-react": "^0.2.9",
|
||||
"@scalar/oas-utils": "0.1.1",
|
||||
"@scalar/api-client-react": "^0.3.7",
|
||||
"@scalar/oas-utils": "0.1.6",
|
||||
"classnames": "^2.5.1",
|
||||
"flatted": "^3.2.9",
|
||||
"openapi-types": "^12.1.3",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getRequestFromOperation,
|
||||
Query,
|
||||
Header,
|
||||
RequestBody,
|
||||
} from '@scalar/oas-utils';
|
||||
import React from 'react';
|
||||
|
||||
@@ -98,7 +99,15 @@ export function ScalarApiClient(props: { children: React.ReactNode }) {
|
||||
{
|
||||
url: operationData.path,
|
||||
},
|
||||
getRequestFromOperation(operation, { requiredOnly: false }),
|
||||
getRequestFromOperation(
|
||||
{
|
||||
...operation,
|
||||
information: {
|
||||
requestBody: operationData.operation.requestBody as RequestBody,
|
||||
},
|
||||
},
|
||||
{ requiredOnly: false },
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { verifyImageSignature, resizeImage, CloudflareImageOptions } from '@/lib/images';
|
||||
import { parseImageAPIURL } from '@/lib/urls';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -10,12 +11,14 @@ export const runtime = 'edge';
|
||||
* Fetch and resize an image.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
let urlParam = request.nextUrl.searchParams.get('url');
|
||||
const signature = request.nextUrl.searchParams.get('sign');
|
||||
if (!url || !signature) {
|
||||
if (!urlParam || !signature) {
|
||||
return new Response('Missing url/sign parameters', { status: 400 });
|
||||
}
|
||||
|
||||
const url = parseImageAPIURL(urlParam);
|
||||
|
||||
// Prevent infinite loops
|
||||
if (url.includes('/~gitbook/image')) {
|
||||
return new Response('Invalid url parameter', { status: 400 });
|
||||
|
||||
@@ -15,7 +15,7 @@ import './theme.css';
|
||||
*/
|
||||
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
|
||||
const { block, document, style, context } = props;
|
||||
const withHighlighting = context.shouldHighlightCode();
|
||||
const withHighlighting = context.shouldHighlightCode(context.content?.spaceId);
|
||||
const lines = withHighlighting ? await highlight(block) : plainHighlighting(block);
|
||||
|
||||
const id = block.key!;
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
const CODE_HIGHLIGHT_BLOCK_LIMIT = 50;
|
||||
|
||||
/**
|
||||
* Spaces where we'll always use plain highlighting.
|
||||
*/
|
||||
const PLAIN_HIGHLIGHTING_SPACES: string[] = [
|
||||
'V9geAO9ITPi8WOYK5o0r',
|
||||
'puRmcwVxGFtHph8IjXaf',
|
||||
'e3jwbMOrr4RhKtZ9C0XL',
|
||||
];
|
||||
|
||||
/**
|
||||
* Protect against memory issues when highlighting a large number of code blocks.
|
||||
* This context only allows 50 code blocks per render to be highlighted.
|
||||
@@ -9,7 +18,11 @@ const CODE_HIGHLIGHT_BLOCK_LIMIT = 50;
|
||||
*/
|
||||
export function createHighlightingContext() {
|
||||
let count = 0;
|
||||
return () => {
|
||||
return (spaceId?: string | undefined) => {
|
||||
if (!spaceId || PLAIN_HIGHLIGHTING_SPACES.includes(spaceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
count += 1;
|
||||
return count < CODE_HIGHLIGHT_BLOCK_LIMIT;
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface DocumentContext {
|
||||
*
|
||||
* https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a
|
||||
*/
|
||||
shouldHighlightCode: () => boolean;
|
||||
shouldHighlightCode: (spaceId: string | undefined) => boolean;
|
||||
}
|
||||
|
||||
export interface DocumentContextProps {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DocumentBlockEmbed } from '@gitbook/api';
|
||||
import * as gitbookAPI from '@gitbook/api';
|
||||
import Script from 'next/script';
|
||||
|
||||
import { Card } from '@/components/primitives';
|
||||
@@ -7,11 +7,14 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { Caption } from './Caption';
|
||||
import { IntegrationBlock } from './Integration';
|
||||
|
||||
export async function Embed(props: BlockProps<DocumentBlockEmbed>) {
|
||||
const { block } = props;
|
||||
export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
||||
const { block, context, ...otherProps } = props;
|
||||
|
||||
const { data: embed } = await api().urls.getEmbedByUrl({ url: block.data.url });
|
||||
const { data: embed } = await (context.content
|
||||
? api().spaces.getEmbedByUrlInSpace(context.content.spaceId, { url: block.data.url })
|
||||
: api().urls.getEmbedByUrl({ url: block.data.url }));
|
||||
|
||||
return (
|
||||
<Caption {...props}>
|
||||
@@ -25,6 +28,12 @@ export async function Embed(props: BlockProps<DocumentBlockEmbed>) {
|
||||
{/* We load the iframely script to resize the embed iframes dynamically */}
|
||||
<Script src="https://cdn.iframe.ly/embed.js" defer async />
|
||||
</>
|
||||
) : embed.type === 'integration' ? (
|
||||
<IntegrationBlock
|
||||
{...otherProps}
|
||||
context={context}
|
||||
block={createIntegrationBlock(block.data.url, embed.integration, embed.block)}
|
||||
/>
|
||||
) : (
|
||||
<Card
|
||||
leadingIcon={
|
||||
@@ -40,3 +49,28 @@ export async function Embed(props: BlockProps<DocumentBlockEmbed>) {
|
||||
</Caption>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an integration block with an unfurl action from the GitBook Embed response.
|
||||
*/
|
||||
function createIntegrationBlock(
|
||||
url: string,
|
||||
integration: string,
|
||||
block: gitbookAPI.IntegrationBlock,
|
||||
): gitbookAPI.DocumentBlockIntegration {
|
||||
return {
|
||||
object: 'block',
|
||||
type: 'integration',
|
||||
isVoid: true,
|
||||
data: {
|
||||
integration,
|
||||
block: block.id,
|
||||
props: {},
|
||||
action: {
|
||||
action: '@link.unfurl',
|
||||
url,
|
||||
},
|
||||
url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import IconDownload from '@geist-ui/icons/download';
|
||||
import IconFileText from '@geist-ui/icons/fileText';
|
||||
import IconImage from '@geist-ui/icons/image';
|
||||
import IconPaperClip from '@geist-ui/icons/paperclip';
|
||||
import { DocumentBlockFile } from '@gitbook/api';
|
||||
|
||||
import { getSimplifiedContentType } from '@/lib/files';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { FileIcon } from './FileIcon';
|
||||
|
||||
export async function File(props: BlockProps<DocumentBlockFile>) {
|
||||
const { block, context, style } = props;
|
||||
@@ -18,42 +16,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = (() => {
|
||||
switch (file.contentType) {
|
||||
case 'application/pdf':
|
||||
case 'application/x-pdf':
|
||||
return 'pdf';
|
||||
case 'image/png':
|
||||
case 'image/jpeg':
|
||||
case 'image/gif':
|
||||
case 'image/webp':
|
||||
case 'image/tiff':
|
||||
case 'image/svg+xml':
|
||||
return 'image';
|
||||
case 'application/zip':
|
||||
case 'application/x-7z-compressed':
|
||||
case 'application/x-zip-compressed':
|
||||
case 'application/x-tar':
|
||||
case 'application/x-rar-compressed':
|
||||
case 'application/vnd.rar':
|
||||
return 'archive';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const icon = (() => {
|
||||
switch (contentType) {
|
||||
case 'pdf':
|
||||
return <IconFileText />;
|
||||
case 'image':
|
||||
return <IconImage />;
|
||||
case 'archive':
|
||||
return <IconPaperClip />;
|
||||
default:
|
||||
return <IconDownload />;
|
||||
}
|
||||
})();
|
||||
const contentType = getSimplifiedContentType(file.contentType);
|
||||
|
||||
return (
|
||||
<a
|
||||
@@ -103,7 +66,9 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
|
||||
'dark:border-light/2',
|
||||
)}
|
||||
>
|
||||
<div className={tcls('*:w-5', '*:h-5', '*:stroke-primary')}>{icon}</div>
|
||||
<div className={tcls('*:w-5', '*:h-5', '*:stroke-primary')}>
|
||||
<FileIcon contentType={contentType} />
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'text-xs',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import IconDownload from '@geist-ui/icons/download';
|
||||
import IconFileText from '@geist-ui/icons/fileText';
|
||||
import IconImage from '@geist-ui/icons/image';
|
||||
import IconPaperClip from '@geist-ui/icons/paperclip';
|
||||
|
||||
import { SimplifiedFileType } from '@/lib/files';
|
||||
|
||||
/**
|
||||
* Render an appropriate icon for a file.
|
||||
*/
|
||||
export function FileIcon(props: { contentType: SimplifiedFileType | null }) {
|
||||
const { contentType } = props;
|
||||
|
||||
switch (contentType) {
|
||||
case 'pdf':
|
||||
return <IconFileText />;
|
||||
case 'image':
|
||||
return <IconImage />;
|
||||
case 'archive':
|
||||
return <IconPaperClip />;
|
||||
default:
|
||||
return <IconDownload />;
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
const initialInput = {
|
||||
componentId: block.data.block,
|
||||
props: block.data.props,
|
||||
action: block.data.action,
|
||||
context: contentKitContext,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,115 +1,123 @@
|
||||
.light .scalar-modal-layout,
|
||||
.light .scalar {
|
||||
--theme-color-1: color-mix(
|
||||
--scalar-color-1: color-mix(
|
||||
in srgb,
|
||||
rgb(var(--primary-base-300, 180 180 180)),
|
||||
rgb(var(--dark-base, 23 23 23)) 96%
|
||||
);
|
||||
--theme-color-2: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.72));
|
||||
--theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4));
|
||||
--theme-color-accent: #007d9c;
|
||||
--scalar-color-2: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.72));
|
||||
--scalar-color-3: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.4));
|
||||
--scalar-color-accent: #007d9c;
|
||||
|
||||
--theme-background-1: rgb(var(--light-base, 255 255 255));
|
||||
--theme-background-2: color-mix(
|
||||
--scalar-background-1: rgb(var(--light-base, 255 255 255));
|
||||
--scalar-background-2: color-mix(
|
||||
in srgb,
|
||||
rgb(var(--primary-base-800, 30 30 30)),
|
||||
var(--theme-background-1) 96%
|
||||
var(--scalar-background-1) 96%
|
||||
);
|
||||
--theme-background-3: color-mix(
|
||||
--scalar-background-3: color-mix(
|
||||
in srgb,
|
||||
rgb(var(--primary-base-800, 30 30 30)),
|
||||
var(--theme-background-1) 90%
|
||||
var(--scalar-background-1) 90%
|
||||
);
|
||||
--theme-background-accent: #007d9c1f;
|
||||
--theme-code-language-color-supersede: var(--theme-color-1);
|
||||
--theme-code-languages-background-supersede: var(--theme-background-1);
|
||||
--theme-border-color: color-mix(
|
||||
--scalar-background-accent: #007d9c1f;
|
||||
--scalar-code-language-color-supersede: var(--scalar-color-1);
|
||||
--scalar-code-languages-background-supersede: var(--scalar-background-1);
|
||||
--scalar-border-color: color-mix(
|
||||
in srgb,
|
||||
var(--theme-color-1),
|
||||
var(--scalar-color-1),
|
||||
transparent calc(100% - 100% * 0.08)
|
||||
);
|
||||
|
||||
--theme-color-green: #0a6355;
|
||||
--theme-color-red: #dc1b19;
|
||||
--theme-color-yellow: #ffc90d;
|
||||
--theme-color-blue: rgb(var(--primary-color-500, 52 109 219));
|
||||
--theme-color-orange: #ff8d4d;
|
||||
--theme-color-purple: #8250df;
|
||||
--scalar-color-green: #0a6355;
|
||||
--scalar-color-red: #dc1b19;
|
||||
--scalar-color-yellow: #ffc90d;
|
||||
--scalar-color-blue: rgb(var(--primary-color-500, 52 109 219));
|
||||
--scalar-color-orange: #ff8d4d;
|
||||
--scalar-color-purple: #8250df;
|
||||
|
||||
--theme-scrollbar-color: rgba(255, 255, 255, 0.24);
|
||||
--theme-scrollbar-color-active: rgba(255, 255, 255, 0.48);
|
||||
--scalar-scrollbar-color: rgba(255, 255, 255, 0.24);
|
||||
--scalar-scrollbar-color-active: rgba(255, 255, 255, 0.48);
|
||||
|
||||
--scalar-button-1: rgb(49 53 56);
|
||||
--scalar-button-1-color: #fff;
|
||||
--scalar-button-1-hover: rgb(28 31 33);
|
||||
}
|
||||
.dark .scalar-modal-layout,
|
||||
.dark .scalar {
|
||||
--theme-color-1: color-mix(
|
||||
--scalar-color-1: color-mix(
|
||||
in srgb,
|
||||
rgb(var(--primary-base-700, 70 70 70)),
|
||||
rgb(var(--light-base, 255 255 255)) 100%
|
||||
);
|
||||
--theme-color-2: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.64));
|
||||
--theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4));
|
||||
--theme-color-accent: #50b7e0;
|
||||
--scalar-color-2: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.64));
|
||||
--scalar-color-3: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.4));
|
||||
--scalar-color-accent: #50b7e0;
|
||||
|
||||
--theme-background-1: rgb(var(--dark-base, 22 22 22));
|
||||
--theme-background-2: color-mix(
|
||||
--scalar-background-1: rgb(var(--dark-base, 22 22 22));
|
||||
--scalar-background-2: color-mix(
|
||||
in srgb,
|
||||
rgb(var(--primary-base-200, 200 200 200)),
|
||||
var(--theme-background-1) 92%
|
||||
var(--scalar-background-1) 92%
|
||||
);
|
||||
--theme-background-3: color-mix(
|
||||
--scalar-background-3: color-mix(
|
||||
in srgb,
|
||||
rgb(var(--primary-base-200, 200 200 200)),
|
||||
var(--theme-background-1) 88%
|
||||
var(--scalar-background-1) 88%
|
||||
);
|
||||
--theme-background-accent: #8ab4f81f;
|
||||
--theme-code-languages-background-supersede: var(--theme-background-1);
|
||||
--theme-border-color: color-mix(
|
||||
--scalar-background-accent: #8ab4f81f;
|
||||
--scalar-code-languages-background-supersede: var(--scalar-background-1);
|
||||
--scalar-border-color: color-mix(
|
||||
in srgb,
|
||||
var(--theme-color-1),
|
||||
var(--scalar-color-1),
|
||||
transparent calc(100% - 100% * 0.08)
|
||||
);
|
||||
|
||||
--theme-color-green: #56b6c2;
|
||||
--theme-color-red: rgb(245 124 97);
|
||||
--theme-color-yellow: #edbe20;
|
||||
--theme-color-blue: rgb(var(--primary-color-400, 93 138 226));
|
||||
--theme-color-orange: #d19a66;
|
||||
--theme-color-purple: #5203d1;
|
||||
--scalar-color-green: #56b6c2;
|
||||
--scalar-color-red: rgb(245 124 97);
|
||||
--scalar-color-yellow: #edbe20;
|
||||
--scalar-color-blue: rgb(var(--primary-color-400, 93 138 226));
|
||||
--scalar-color-orange: #d19a66;
|
||||
--scalar-color-purple: #5203d1;
|
||||
|
||||
--theme-scrollbar-color: rgba(0, 0, 0, 0.18);
|
||||
--theme-scrollbar-color-active: rgba(0, 0, 0, 0.36);
|
||||
--scalar-scrollbar-color: rgba(0, 0, 0, 0.18);
|
||||
--scalar-scrollbar-color-active: rgba(0, 0, 0, 0.36);
|
||||
|
||||
--scalar-button-1: #f6f6f6;
|
||||
--scalar-button-1-color: #000;
|
||||
--scalar-button-1-hover: #e7e7e7;
|
||||
}
|
||||
.scalar-modal-layout,
|
||||
.scalar {
|
||||
--theme-font: initial;
|
||||
--theme-font-code: var(--font-mono);
|
||||
--scalar-font: initial;
|
||||
--scalar-font-code: var(--font-mono);
|
||||
|
||||
--theme-paragraph: 16px;
|
||||
--theme-small: 14px;
|
||||
--theme-mini: 13px;
|
||||
--theme-micro: 12px;
|
||||
--scalar-paragraph: 16px;
|
||||
--scalar-small: 14px;
|
||||
--scalar-mini: 13px;
|
||||
--scalar-micro: 12px;
|
||||
|
||||
--theme-bold: 600;
|
||||
--theme-semibold: 500;
|
||||
--theme-regular: 400;
|
||||
--scalar-bold: 600;
|
||||
--scalar-semibold: 500;
|
||||
--scalar-regular: 400;
|
||||
|
||||
/* Font sizes for interactive applications (not rendered text content) */
|
||||
--theme-font-size-1: 24px;
|
||||
--theme-font-size-2: 16px;
|
||||
--theme-font-size-3: 14px;
|
||||
--theme-font-size-4: 13px;
|
||||
--theme-font-size-5: 12px;
|
||||
--scalar-font-size-1: 24px;
|
||||
--scalar-font-size-2: 16px;
|
||||
--scalar-font-size-3: 14px;
|
||||
--scalar-font-size-4: 13px;
|
||||
--scalar-font-size-5: 12px;
|
||||
|
||||
--theme-line-height-1: 32px;
|
||||
--theme-line-height-2: 24px;
|
||||
--theme-line-height-3: 20px;
|
||||
--theme-line-height-4: 18px;
|
||||
--theme-line-height-5: 16px;
|
||||
--scalar-line-height-1: 32px;
|
||||
--scalar-line-height-2: 24px;
|
||||
--scalar-line-height-3: 20px;
|
||||
--scalar-line-height-4: 18px;
|
||||
--scalar-line-height-5: 16px;
|
||||
|
||||
--scalar-app-header-height: 35px;
|
||||
}
|
||||
.scalar input::placeholder {
|
||||
color: var(--theme-color-3);
|
||||
color: var(--scalar-color-3);
|
||||
}
|
||||
.scalar .scalar-app-header {
|
||||
width: 100%;
|
||||
@@ -134,15 +142,15 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
color: var(--theme-color-1);
|
||||
font-size: var(--theme-small);
|
||||
font-weight: var(--theme-semibold);
|
||||
color: var(--scalar-color-1);
|
||||
font-size: var(--scalar-small);
|
||||
font-weight: var(--scalar-semibold);
|
||||
}
|
||||
.scalar-api-client__close:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.scalar .scalar-app {
|
||||
background: var(--theme-background-3);
|
||||
background: var(--scalar-background-3);
|
||||
height: calc(100dvh - 100px);
|
||||
max-width: 1280px;
|
||||
width: 100%;
|
||||
@@ -227,10 +235,10 @@
|
||||
line-height: normal;
|
||||
}
|
||||
.scalar .scalar-app-header span {
|
||||
color: var(--theme-color-3);
|
||||
color: var(--scalar-color-3);
|
||||
}
|
||||
.scalar .scalar-app-header a {
|
||||
color: var(--theme-color-1);
|
||||
color: var(--scalar-color-1);
|
||||
}
|
||||
.scalar .scalar-app-header a:hover {
|
||||
text-decoration: underline;
|
||||
@@ -251,7 +259,7 @@
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
color: var(--theme-color-blue);
|
||||
color: var(--scalar-color-blue);
|
||||
appearance: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
@@ -261,7 +269,7 @@
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
.scalar-activate:hover .scalar-activate-button {
|
||||
background: var(--theme-background-3);
|
||||
background: var(--scalar-background-3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -293,12 +301,12 @@
|
||||
scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
|
||||
}
|
||||
.scalar .custom-scroll:hover::-webkit-scrollbar-thumb {
|
||||
background: var(--theme-scrollbar-color, var(--default-theme-scrollbar-color));
|
||||
background: var(--scalar-scrollbar-color, var(--default-theme-scrollbar-color));
|
||||
background-clip: content-box;
|
||||
border: 3px solid transparent;
|
||||
}
|
||||
.scalar .custom-scroll::-webkit-scrollbar-thumb:active {
|
||||
background: var(--theme-scrollbar-color-active, var(--default-theme-scrollbar-color-active));
|
||||
background: var(--scalar-scrollbar-color-active, var(--default-theme-scrollbar-color-active));
|
||||
background-clip: content-box;
|
||||
border: 3px solid transparent;
|
||||
}
|
||||
@@ -322,4 +330,4 @@
|
||||
.scalar .custom-scroll {
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import assertNever from 'assert-never';
|
||||
|
||||
import { Checkbox, Emoji } from '@/components/primitives';
|
||||
import { StyledLink } from '@/components/primitives';
|
||||
import { Image } from '@/components/utils';
|
||||
import { getNodeFragmentByName } from '@/lib/document';
|
||||
import { getSimplifiedContentType } from '@/lib/files';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { filterOutNullable } from '@/lib/typescript';
|
||||
|
||||
@@ -12,6 +14,7 @@ import { TableRecordKV } from './Table';
|
||||
import { getColumnAlignment } from './utils';
|
||||
import { BlockProps } from '../Block';
|
||||
import { Blocks } from '../Blocks';
|
||||
import { FileIcon } from '../FileIcon';
|
||||
|
||||
/**
|
||||
* Render the value for a column in a record.
|
||||
@@ -125,11 +128,41 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
|
||||
return (
|
||||
<Tag className={tcls('text-base')}>
|
||||
{files.filter(filterOutNullable).map((file, index) => (
|
||||
<StyledLink key={index} href={file.href}>
|
||||
{file.text}
|
||||
</StyledLink>
|
||||
))}
|
||||
{files.filter(filterOutNullable).map((ref, index) => {
|
||||
const contentType = ref.file
|
||||
? getSimplifiedContentType(ref.file.contentType)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<StyledLink
|
||||
key={index}
|
||||
href={ref.href}
|
||||
target="_blank"
|
||||
style={['flex', 'flex-row', 'items-center', 'gap-2']}
|
||||
>
|
||||
{contentType === 'image' ? (
|
||||
<Image
|
||||
style={['max-h-[1lh]', 'h-[1lh]']}
|
||||
alt={ref.text}
|
||||
sizes={[{ width: 24 }]}
|
||||
sources={{
|
||||
light: {
|
||||
src: ref.href,
|
||||
size: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
},
|
||||
}}
|
||||
priority="lazy"
|
||||
/>
|
||||
) : (
|
||||
<FileIcon contentType={contentType} />
|
||||
)}
|
||||
{ref.text}
|
||||
</StyledLink>
|
||||
);
|
||||
})}
|
||||
</Tag>
|
||||
);
|
||||
case 'content-ref': {
|
||||
|
||||
@@ -75,7 +75,7 @@ export function TableOfContents(props: {
|
||||
'dark:group-hover:[&::-webkit-scrollbar-thumb]:bg-light/3',
|
||||
'navigation-open:flex', // can be auto height animated as such https://stackoverflow.com/a/76944290
|
||||
'lg:-ml-5',
|
||||
customization.trademark.enabled ? 'lg:pb-16' : 'lg:pb-4',
|
||||
customization.trademark.enabled ? 'lg:pb-20' : 'lg:pb-4',
|
||||
)}
|
||||
>
|
||||
<PagesList
|
||||
|
||||
@@ -84,16 +84,20 @@ export function TrademarkLink(props: {
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'hover:bg-dark/1',
|
||||
'bg-light',
|
||||
'dark:bg-dark',
|
||||
'px-4',
|
||||
'py-2',
|
||||
'rounded-md',
|
||||
'py-4',
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-none',
|
||||
'hover:backdrop-blur-sm',
|
||||
'lg:ring-0',
|
||||
'tracking-[-0.016em]',
|
||||
'dark:hover:bg-light/1',
|
||||
'dark:ring-light/1',
|
||||
'dark:font-normal',
|
||||
'border',
|
||||
'border-dark/2',
|
||||
'dark:border-light/2',
|
||||
)}
|
||||
>
|
||||
<IconLogo className={tcls('w-5', 'h-5', 'mr-3')} />
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Link, LinkProps } from '../primitives/Link';
|
||||
|
||||
/**
|
||||
* Styled version of Link component.
|
||||
*/
|
||||
export function StyledLink(props: LinkProps) {
|
||||
export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValue }) {
|
||||
const { style, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
{...props}
|
||||
{...rest}
|
||||
className={tcls(
|
||||
'underline',
|
||||
'underline-offset-2',
|
||||
@@ -16,6 +18,7 @@ export function StyledLink(props: LinkProps) {
|
||||
'text-primary',
|
||||
'hover:text-primary-700',
|
||||
'transition-colors',
|
||||
style,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export type SimplifiedFileType = 'image' | 'pdf' | 'archive';
|
||||
|
||||
/**
|
||||
* Get a simplified content type for the given mime type.
|
||||
*/
|
||||
export function getSimplifiedContentType(mimeType: string): SimplifiedFileType | null {
|
||||
if (mimeType.startsWith('image')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
switch (mimeType) {
|
||||
case 'application/pdf':
|
||||
case 'application/x-pdf':
|
||||
return 'pdf';
|
||||
case 'application/zip':
|
||||
case 'application/x-7z-compressed':
|
||||
case 'application/x-zip-compressed':
|
||||
case 'application/x-tar':
|
||||
case 'application/x-rar-compressed':
|
||||
case 'application/vnd.rar':
|
||||
return 'archive';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -3,6 +3,7 @@ import 'server-only';
|
||||
import { noCacheFetchOptions } from '@/lib/cache/http';
|
||||
|
||||
import { rootUrl } from './links';
|
||||
import { getImageAPIUrl } from './urls';
|
||||
|
||||
export interface CloudflareImageJsonFormat {
|
||||
width: number;
|
||||
@@ -76,7 +77,7 @@ export async function getResizedImageURL(
|
||||
|
||||
return (options) => {
|
||||
const url = new URL('/~gitbook/image', rootUrl());
|
||||
url.searchParams.set('url', input);
|
||||
url.searchParams.set('url', getImageAPIUrl(input));
|
||||
|
||||
if (options.width) {
|
||||
url.searchParams.set('width', options.width.toString());
|
||||
|
||||
@@ -65,3 +65,21 @@ export function getPDFUrlSearchParams(
|
||||
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost.
|
||||
* We protect against it by converting to a special token, and then parsing
|
||||
* the token in the image API.
|
||||
*/
|
||||
const GITBOOK_LOCALHOST_TOKEN = '$GITBOOK_LOCALHOST$';
|
||||
|
||||
/**
|
||||
* Prepare a URL for the GitBook Open Image API.
|
||||
*/
|
||||
export function getImageAPIUrl(url: string): string {
|
||||
return url.replaceAll('127.0.0.1', GITBOOK_LOCALHOST_TOKEN);
|
||||
}
|
||||
|
||||
export function parseImageAPIURL(url: string): string {
|
||||
return url.replaceAll(GITBOOK_LOCALHOST_TOKEN, '127.0.0.1');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { it, describe, expect } from 'bun:test';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import {
|
||||
VisitorAuthCookieValue,
|
||||
getVisitorAuthCookieName,
|
||||
getVisitorAuthCookieValue,
|
||||
getVisitorAuthToken,
|
||||
@@ -17,14 +18,18 @@ describe('getVisitorAuthToken', () => {
|
||||
const request = nextRequest('https://example.com', {
|
||||
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', '123') },
|
||||
});
|
||||
expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
|
||||
const visitorAuth = getVisitorAuthToken(request, request.nextUrl);
|
||||
assertVisitorAuthCookieValue(visitorAuth);
|
||||
expect(visitorAuth.token).toEqual('123');
|
||||
});
|
||||
|
||||
it('should return the token from the cookie root basepath for a sub-path', () => {
|
||||
const request = nextRequest('https://example.com/hello/world', {
|
||||
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', '123') },
|
||||
});
|
||||
expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
|
||||
const visitorAuth = getVisitorAuthToken(request, request.nextUrl);
|
||||
assertVisitorAuthCookieValue(visitorAuth);
|
||||
expect(visitorAuth.token).toEqual('123');
|
||||
});
|
||||
|
||||
it('should return the closest token from the path', () => {
|
||||
@@ -34,7 +39,9 @@ describe('getVisitorAuthToken', () => {
|
||||
value: getVisitorAuthCookieValue('/hello/', '123'),
|
||||
},
|
||||
});
|
||||
expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
|
||||
const visitorAuth = getVisitorAuthToken(request, request.nextUrl);
|
||||
assertVisitorAuthCookieValue(visitorAuth);
|
||||
expect(visitorAuth.token).toEqual('123');
|
||||
});
|
||||
|
||||
it('should return the token from the cookie in a collection type url', () => {
|
||||
@@ -43,7 +50,9 @@ describe('getVisitorAuthToken', () => {
|
||||
value: getVisitorAuthCookieValue('/hello/v/space1/', '123'),
|
||||
},
|
||||
});
|
||||
expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
|
||||
const visitorAuth = getVisitorAuthToken(request, request.nextUrl);
|
||||
assertVisitorAuthCookieValue(visitorAuth);
|
||||
expect(visitorAuth.token).toEqual('123');
|
||||
});
|
||||
|
||||
it('should return undefined if no cookie and no query param', () => {
|
||||
@@ -52,6 +61,14 @@ describe('getVisitorAuthToken', () => {
|
||||
});
|
||||
});
|
||||
|
||||
function assertVisitorAuthCookieValue(value: unknown): asserts value is VisitorAuthCookieValue {
|
||||
if (value && typeof value === 'object' && 'token' in value) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('Expected a VisitorAuthCookieValue');
|
||||
}
|
||||
|
||||
function nextRequest(url: string, cookies: Record<string, { value: string }> = {}) {
|
||||
const nextUrl = new URL(url);
|
||||
// @ts-ignore
|
||||
|
||||
+20
-11
@@ -16,7 +16,10 @@ export type VisitorAuthCookieValue = {
|
||||
* Get the visitor authentication token for the request. This token can either be in the
|
||||
* query parameters or stored as a cookie.
|
||||
*/
|
||||
export function getVisitorAuthToken(request: NextRequest, url: URL): string | undefined {
|
||||
export function getVisitorAuthToken(
|
||||
request: NextRequest,
|
||||
url: URL,
|
||||
): string | VisitorAuthCookieValue | undefined {
|
||||
return url.searchParams.get(VISITOR_AUTH_PARAM) ?? getVisitorAuthTokenFromCookies(request, url);
|
||||
}
|
||||
|
||||
@@ -68,7 +71,10 @@ function getUrlBasePathCombinations(url: URL): string[] {
|
||||
* checking all cookies for a matching "visitor authentication cookie" and returning the
|
||||
* best possible match for the current URL.
|
||||
*/
|
||||
function getVisitorAuthTokenFromCookies(request: NextRequest, url: URL): string | undefined {
|
||||
function getVisitorAuthTokenFromCookies(
|
||||
request: NextRequest,
|
||||
url: URL,
|
||||
): VisitorAuthCookieValue | undefined {
|
||||
const urlBasePaths = getUrlBasePathCombinations(url);
|
||||
// Try to find a visitor authentication token for the current URL. The request
|
||||
// for the content could be hosted on a base path like `/foo/v/bar` or `/foo` or just `/`
|
||||
@@ -90,14 +96,17 @@ function getVisitorAuthTokenFromCookies(request: NextRequest, url: URL): string
|
||||
function findVisitorAuthCookieForBasePath(
|
||||
request: NextRequest,
|
||||
basePath: string,
|
||||
): string | undefined {
|
||||
return Array.from(request.cookies).reduce<string | undefined>((acc, [name, cookie]) => {
|
||||
if (name === getVisitorAuthCookieName(basePath)) {
|
||||
const value = JSON.parse(cookie.value) as VisitorAuthCookieValue;
|
||||
if (value.basePath === basePath) {
|
||||
acc = value.token;
|
||||
): VisitorAuthCookieValue | undefined {
|
||||
return Array.from(request.cookies).reduce<VisitorAuthCookieValue | undefined>(
|
||||
(acc, [name, cookie]) => {
|
||||
if (name === getVisitorAuthCookieName(basePath)) {
|
||||
const value = JSON.parse(cookie.value) as VisitorAuthCookieValue;
|
||||
if (value.basePath === basePath) {
|
||||
acc = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, undefined);
|
||||
return acc;
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
+45
-21
@@ -22,6 +22,7 @@ import { buildVersion } from '@/lib/build';
|
||||
import { createContentSecurityPolicyNonce, getContentSecurityPolicy } from '@/lib/csp';
|
||||
import { getURLLookupAlternatives, normalizeURL } from '@/lib/middleware';
|
||||
import {
|
||||
VisitorAuthCookieValue,
|
||||
getVisitorAuthCookieName,
|
||||
getVisitorAuthCookieValue,
|
||||
getVisitorAuthToken,
|
||||
@@ -79,6 +80,7 @@ interface ContentAPITokenPayload {
|
||||
spaces: string[];
|
||||
collection?: string;
|
||||
site?: string;
|
||||
siteSpace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -484,15 +486,15 @@ async function lookupSpaceInMultiIdMode(request: NextRequest, url: URL): Promise
|
||||
};
|
||||
}
|
||||
|
||||
const decoded = jwt.decode(apiToken) as ContentAPITokenPayload;
|
||||
const { organization, site, siteSpace } = jwt.decode(apiToken) as ContentAPITokenPayload;
|
||||
const siteLookupResult =
|
||||
typeof decoded.site === 'string' &&
|
||||
decoded.site &&
|
||||
typeof decoded.organization === 'string' &&
|
||||
decoded.organization
|
||||
? { site: decoded.site, organization: decoded.organization }
|
||||
typeof organization === 'string' && organization && typeof site === 'string' && site
|
||||
? {
|
||||
organization,
|
||||
site,
|
||||
...(typeof siteSpace === 'string' && siteSpace ? { siteSpace } : {}),
|
||||
}
|
||||
: {};
|
||||
|
||||
return {
|
||||
space: spaceId,
|
||||
changeRequest: changeRequestId,
|
||||
@@ -587,7 +589,7 @@ async function lookupSpaceInMultiPathMode(request: NextRequest, url: URL): Promi
|
||||
*/
|
||||
async function lookupSpaceByAPI(
|
||||
lookupURL: URL,
|
||||
visitorAuthToken: string | undefined,
|
||||
visitorAuthToken: ReturnType<typeof getVisitorAuthToken>,
|
||||
): Promise<LookupResult> {
|
||||
const url = stripURLSearch(lookupURL);
|
||||
const lookup = getURLLookupAlternatives(url);
|
||||
@@ -597,9 +599,17 @@ async function lookupSpaceByAPI(
|
||||
);
|
||||
|
||||
const result = await race(lookup.urls, async (alternative, { signal }) => {
|
||||
const data = await getPublishedContentByUrl(alternative.url, visitorAuthToken, {
|
||||
signal,
|
||||
});
|
||||
const data = await getPublishedContentByUrl(
|
||||
alternative.url,
|
||||
typeof visitorAuthToken === 'undefined'
|
||||
? undefined
|
||||
: typeof visitorAuthToken === 'string'
|
||||
? visitorAuthToken
|
||||
: visitorAuthToken.token,
|
||||
{
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
if ('error' in data) {
|
||||
if (alternative.primary) {
|
||||
@@ -671,22 +681,36 @@ async function lookupSpaceByAPI(
|
||||
*/
|
||||
function getLookupResultForVisitorAuth(
|
||||
basePath: string,
|
||||
visitorAuthToken: string,
|
||||
visitorAuthToken: string | VisitorAuthCookieValue,
|
||||
): Partial<LookupResult> {
|
||||
return {
|
||||
// No caching for content served with visitor auth
|
||||
cacheMaxAge: undefined,
|
||||
cacheTags: [],
|
||||
cookies: {
|
||||
[getVisitorAuthCookieName(basePath)]: {
|
||||
value: getVisitorAuthCookieValue(basePath, visitorAuthToken),
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'none',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
},
|
||||
},
|
||||
/**
|
||||
* If the visitorAuthToken has been retrieved from a cookie, we set it back only
|
||||
* if the basePath matches the current one. This is to avoid setting cookie for
|
||||
* different base paths.
|
||||
*/
|
||||
...(typeof visitorAuthToken === 'string' || visitorAuthToken.basePath === basePath
|
||||
? {
|
||||
[getVisitorAuthCookieName(basePath)]: {
|
||||
value: getVisitorAuthCookieValue(
|
||||
basePath,
|
||||
typeof visitorAuthToken === 'string'
|
||||
? visitorAuthToken
|
||||
: visitorAuthToken.token,
|
||||
),
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'none',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user