mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-27 20:48:47 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00bb7d4220 | |||
| 259a8aa7ac | |||
| 150269a6ee |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix an issue with the cookie banner buttons being non responsive
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Support Integers in Response example
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix security issue with image resizing that could be used for phishing
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix - whitespace added to site section tabs with icons.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Add icons to sections
|
||||
@@ -16,7 +16,7 @@
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.78.0",
|
||||
"@gitbook/api": "^0.77.0",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
verifyImageSignature,
|
||||
resizeImage,
|
||||
CloudflareImageOptions,
|
||||
imagesResizingSignVersion,
|
||||
checkIsSizableImageURL,
|
||||
} from '@/lib/images';
|
||||
import { parseImageAPIURL } from '@/lib/urls';
|
||||
@@ -19,29 +18,28 @@ export const runtime = 'edge';
|
||||
export async function GET(request: NextRequest) {
|
||||
let urlParam = request.nextUrl.searchParams.get('url');
|
||||
const signature = request.nextUrl.searchParams.get('sign');
|
||||
|
||||
// The current signature algorithm sets version as 2, but we need to support the older version as well
|
||||
const signatureVersion = request.nextUrl.searchParams.get('sv') as string | undefined;
|
||||
// The current signature algorithm sets version as 1, but we need to support the older version as well
|
||||
// for previously generated content. In this case, we default to version 0.
|
||||
const signatureVersion = (request.nextUrl.searchParams.get('sv') as '1') || '0';
|
||||
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 });
|
||||
}
|
||||
|
||||
// Check again if the image can be sized, even though we checked when rendering the Image component
|
||||
// Otherwise, it's possible to pass just any link to this endpoint and trigger HTML injection on the domain
|
||||
// Also prevent infinite redirects.
|
||||
if (!checkIsSizableImageURL(url)) {
|
||||
return new Response('Invalid url parameter', { status: 400 });
|
||||
}
|
||||
|
||||
// For older signatures, we redirect to the url.
|
||||
if (signatureVersion !== imagesResizingSignVersion) {
|
||||
return Response.redirect(url, 302);
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
const verified = await verifyImageSignature(url, { signature });
|
||||
const verified = await verifyImageSignature(url, { signature, version: signatureVersion });
|
||||
if (!verified) {
|
||||
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -91,8 +91,6 @@ const HINT_STYLES: {
|
||||
'[&_.can-override-bg]:bg-orange-500/3',
|
||||
'[&_.can-override-text]:text-orange-800',
|
||||
'dark:[&_.can-override-text]:text-orange-400',
|
||||
'decoration-orange-800/6',
|
||||
'dark:decoration-orange-400/6',
|
||||
],
|
||||
style: ['bg-orange-500/2', 'border-orange-500/4'],
|
||||
},
|
||||
@@ -106,8 +104,6 @@ const HINT_STYLES: {
|
||||
'dark:[&_a:hover]:text-red-300',
|
||||
'[&_.can-override-bg]:bg-red-500/3',
|
||||
'[&_.can-override-text]:text-red-400',
|
||||
'decoration-red-800/6',
|
||||
'dark:decoration-red-400/6',
|
||||
],
|
||||
style: ['bg-red-500/2', 'border-red-500/4'],
|
||||
},
|
||||
@@ -122,8 +118,6 @@ const HINT_STYLES: {
|
||||
'[&_.can-override-bg]:bg-green-500/3',
|
||||
'[&_.can-override-text]:text-green-800',
|
||||
'dark:[&_.can-override-text]:text-green-400',
|
||||
'decoration-green-800/6',
|
||||
'dark:decoration-green-400/6',
|
||||
],
|
||||
style: ['bg-green-500/2', 'border-green-500/4'],
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ export function FooterLinksGroup(props: {
|
||||
|
||||
return (
|
||||
<div className={tcls('flex', 'flex-col', 'gap-3')}>
|
||||
<p className={tcls('text-base', 'font-semibold')}>{group.title}</p>
|
||||
<p className={tcls('text-base', 'font-medium')}>{group.title}</p>
|
||||
{group.links.map((link, index) => {
|
||||
return <FooterLink key={index} link={link} context={context} />;
|
||||
})}
|
||||
@@ -32,14 +32,7 @@ async function FooterLink(props: { link: CustomizationContentLink; context: Cont
|
||||
return (
|
||||
<Link
|
||||
href={resolved.href}
|
||||
className={tcls(
|
||||
'text-sm',
|
||||
'font-normal',
|
||||
'text-dark/8',
|
||||
'hover:text-dark/9',
|
||||
'dark:text-light/8',
|
||||
'dark:hover:text-light/9',
|
||||
)}
|
||||
className={tcls('text-sm', 'text-primary-400', 'hover:text-primary-500')}
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
|
||||
@@ -82,12 +82,11 @@ export function Dropdown<E extends HTMLElement>(props: {
|
||||
/**
|
||||
* Animated chevron to display in the dropdown button.
|
||||
*/
|
||||
export function DropdownChevron() {
|
||||
export function DropdownChevron(props: {}) {
|
||||
return (
|
||||
<Icon
|
||||
icon="chevron-down"
|
||||
className={tcls(
|
||||
'shrink-0',
|
||||
'opacity-6',
|
||||
'size-3',
|
||||
'ms-1',
|
||||
|
||||
@@ -45,27 +45,27 @@ export function Header(props: {
|
||||
'w-full',
|
||||
'flex-none',
|
||||
'shadow-thinbottom',
|
||||
'dark:shadow-light/2',
|
||||
withTopHeader ? null : 'lg:hidden',
|
||||
'lg:z-10',
|
||||
'dark:shadow-light/1',
|
||||
`${isCustomizationDefault || !withTopHeader ? 'bg-light' : 'bg-header-background'}`,
|
||||
`${
|
||||
isCustomizationDefault || !withTopHeader
|
||||
? 'dark:bg-dark'
|
||||
: 'bg-header-background'
|
||||
}`,
|
||||
isCustomizationDefault || !withTopHeader
|
||||
? ['bg-light', 'dark:bg-dark']
|
||||
: ['bg-header-background', 'bg-header-background'],
|
||||
'text-sm',
|
||||
'bg-opacity-9',
|
||||
'dark:bg-opacity-9',
|
||||
'backdrop-blur-lg',
|
||||
'contrast-more:bg-opacity-11',
|
||||
'contrast-more:dark:bg-opacity-11',
|
||||
)}
|
||||
>
|
||||
<div className={tcls('scroll-nojump')}>
|
||||
<div
|
||||
className={tcls(
|
||||
'gap-4',
|
||||
'grid',
|
||||
'grid-flow-col',
|
||||
'auto-cols-[auto_auto_1fr_auto]',
|
||||
'lg:gap-8',
|
||||
'flex',
|
||||
'h-16',
|
||||
'items-center',
|
||||
'align-center',
|
||||
'justify-between',
|
||||
'w-full',
|
||||
CONTAINER_STYLE,
|
||||
@@ -109,21 +109,21 @@ export function Header(props: {
|
||||
style={
|
||||
!isCustomizationDefault && withTopHeader
|
||||
? [
|
||||
'bg-header-link/3',
|
||||
'shadow-sm',
|
||||
'ring-header-link/3',
|
||||
'[&>span]:!text-header-link/7',
|
||||
'[&_svg]:text-header-link',
|
||||
'contrast-more:bg-transparent',
|
||||
'contrast-more:ring-header-link',
|
||||
'contrast-more:[&>span]:!text-header-link',
|
||||
'dark:bg-header-link/3',
|
||||
'dark:ring-header-link/3',
|
||||
'[&>span]:!text-header-link/7',
|
||||
'dark:[&_svg]:text-header-link',
|
||||
'dark:contrast-more:bg-transparent',
|
||||
'dark:contrast-more:ring-header-link',
|
||||
'dark:contrast-more:[&>span]:!text-header-link',
|
||||
// 'bg-header-link/3',
|
||||
// 'shadow-sm',
|
||||
// 'ring-header-link/3',
|
||||
// '[&>span]:!text-header-link/7',
|
||||
// '[&_svg]:text-header-link',
|
||||
// 'contrast-more:bg-transparent',
|
||||
// 'contrast-more:ring-header-link',
|
||||
// 'contrast-more:[&>span]:!text-header-link',
|
||||
// 'dark:bg-header-link/3',
|
||||
// 'dark:ring-header-link/3',
|
||||
// '[&>span]:!text-header-link/7',
|
||||
// 'dark:[&_svg]:text-header-link',
|
||||
// 'dark:contrast-more:bg-transparent',
|
||||
// 'dark:contrast-more:ring-header-link',
|
||||
// 'dark:contrast-more:[&>span]:!text-header-link',
|
||||
]
|
||||
: null
|
||||
}
|
||||
@@ -142,7 +142,7 @@ export function Header(props: {
|
||||
{sections ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'w-full shadow-thintop dark:shadow-light/1 bg-light dark:bg-dark mt-0.5',
|
||||
'w-full',
|
||||
// Handle long section tabs, particularly on smaller screens.
|
||||
'overflow-x-auto hide-scroll',
|
||||
)}
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import {
|
||||
CustomizationContentLink,
|
||||
CustomizationHeaderLink,
|
||||
CustomizationSettings,
|
||||
CustomizationHeaderPreset,
|
||||
SiteCustomizationSettings,
|
||||
CustomizationHeaderLink,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
// @TODO replace by api.CustomizationHeaderItem when available
|
||||
type CustomizationHeaderItem = Omit<CustomizationHeaderLink, 'to'> & {
|
||||
to: CustomizationHeaderLink['to'] | null;
|
||||
};
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownButtonProps,
|
||||
@@ -26,40 +21,97 @@ import { Button, Link } from '../primitives';
|
||||
|
||||
export async function HeaderLink(props: {
|
||||
context: ContentRefContext;
|
||||
link: CustomizationHeaderItem;
|
||||
link: CustomizationHeaderLink;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
}) {
|
||||
const { context, link, customization } = props;
|
||||
|
||||
const target = link.to ? await resolveContentRef(link.to, context) : null;
|
||||
const target = await resolveContentRef(link.to, context);
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headerPreset = customization.header.preset;
|
||||
const linkStyle = link.style ?? 'link';
|
||||
|
||||
const renderLink = (linkProps: DropdownButtonProps<HTMLAnchorElement>) => {
|
||||
const linkStyle = link.style ?? 'link';
|
||||
|
||||
switch (linkStyle) {
|
||||
case 'button-secondary':
|
||||
case 'button-primary': {
|
||||
const variant = (() => {
|
||||
switch (linkStyle) {
|
||||
case 'button-secondary':
|
||||
return 'secondary';
|
||||
case 'button-primary':
|
||||
return 'primary';
|
||||
default:
|
||||
assertNever(linkStyle);
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<Button
|
||||
href={target.href}
|
||||
variant={variant}
|
||||
size="medium"
|
||||
className={tcls(
|
||||
{
|
||||
'button-primary':
|
||||
headerPreset === CustomizationHeaderPreset.Custom ||
|
||||
headerPreset === CustomizationHeaderPreset.Bold
|
||||
? tcls(
|
||||
'bg-header-link-500 hover:bg-text-header-link-300 text-header-button-text',
|
||||
'dark:bg-header-link-500 dark:hover:bg-text-header-link-300 dark:text-header-button-text',
|
||||
)
|
||||
: null,
|
||||
'button-secondary': tcls(
|
||||
'bg:transparent hover:bg-transparent',
|
||||
'dark:bg-transparent dark:hover:bg-transparent',
|
||||
'ring-header-link-500 hover:ring-header-link-300 text-header-link-500',
|
||||
'dark:ring-header-link-500 dark:hover:ring-header-link-300 dark:text-header-link-500',
|
||||
),
|
||||
}[linkStyle],
|
||||
)}
|
||||
>
|
||||
{link.title}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
case 'link': {
|
||||
return (
|
||||
<Link
|
||||
{...linkProps}
|
||||
href={target.href}
|
||||
className={tcls(
|
||||
'overflow-hidden',
|
||||
|
||||
'flex flex-row items-center',
|
||||
'whitespace-nowrap',
|
||||
|
||||
headerPreset === CustomizationHeaderPreset.Default
|
||||
? [
|
||||
'text-dark/8',
|
||||
'dark:text-light/8',
|
||||
'hover:text-primary',
|
||||
'dark:hover:text-primary',
|
||||
]
|
||||
: ['text-header-link hover:text-header-link/8'],
|
||||
)}
|
||||
>
|
||||
<span className={tcls('truncate')}>{link.title}</span>
|
||||
{link.links && link.links.length > 0 ? <DropdownChevron /> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
default:
|
||||
assertNever(linkStyle);
|
||||
}
|
||||
};
|
||||
|
||||
if (link.links && link.links.length > 0) {
|
||||
return (
|
||||
<Dropdown
|
||||
button={(buttonProps) => {
|
||||
if (!target) {
|
||||
return (
|
||||
<HeaderItemDropdown
|
||||
{...buttonProps}
|
||||
headerPreset={headerPreset}
|
||||
title={link.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HeaderLinkNavItem
|
||||
{...buttonProps}
|
||||
linkStyle={linkStyle}
|
||||
headerPreset={headerPreset}
|
||||
title={link.title}
|
||||
isDropdown
|
||||
href={target?.href}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Dropdown button={renderLink}>
|
||||
<DropdownMenu>
|
||||
{link.links.map((subLink, index) => (
|
||||
<SubHeaderLink key={index} {...props} link={subLink} />
|
||||
@@ -69,128 +121,7 @@ export async function HeaderLink(props: {
|
||||
);
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HeaderLinkNavItem
|
||||
linkStyle={linkStyle}
|
||||
headerPreset={headerPreset}
|
||||
title={link.title}
|
||||
isDropdown={false}
|
||||
href={target.href}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type HeaderLinkNavItemProps = {
|
||||
linkStyle: NonNullable<CustomizationHeaderLink['style']>;
|
||||
headerPreset: CustomizationHeaderPreset;
|
||||
title: string;
|
||||
href: string;
|
||||
isDropdown: boolean;
|
||||
} & DropdownButtonProps<HTMLElement>;
|
||||
|
||||
function HeaderLinkNavItem(props: HeaderLinkNavItemProps) {
|
||||
switch (props.linkStyle) {
|
||||
case 'button-secondary':
|
||||
case 'button-primary':
|
||||
return <HeaderItemButton {...props} linkStyle={props.linkStyle} />;
|
||||
case 'link':
|
||||
return <HeaderItemLink {...props} />;
|
||||
default:
|
||||
assertNever(props.linkStyle);
|
||||
}
|
||||
}
|
||||
|
||||
function HeaderItemButton(
|
||||
props: Omit<HeaderLinkNavItemProps, 'linkStyle'> & {
|
||||
linkStyle: 'button-secondary' | 'button-primary';
|
||||
},
|
||||
) {
|
||||
const { linkStyle, headerPreset, title, href, ...rest } = props;
|
||||
const variant = (() => {
|
||||
switch (linkStyle) {
|
||||
case 'button-secondary':
|
||||
return 'secondary';
|
||||
case 'button-primary':
|
||||
return 'primary';
|
||||
default:
|
||||
assertNever(linkStyle);
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<Button
|
||||
href={href}
|
||||
variant={variant}
|
||||
size="medium"
|
||||
className={tcls(
|
||||
{
|
||||
'button-primary':
|
||||
headerPreset === CustomizationHeaderPreset.Custom ||
|
||||
headerPreset === CustomizationHeaderPreset.Bold
|
||||
? tcls(
|
||||
'bg-header-link-500 hover:bg-text-header-link-300 text-header-button-text',
|
||||
'dark:bg-header-link-500 dark:hover:bg-text-header-link-300 dark:text-header-button-text',
|
||||
)
|
||||
: null,
|
||||
'button-secondary': tcls(
|
||||
'bg:transparent hover:bg-transparent',
|
||||
'dark:bg-transparent dark:hover:bg-transparent',
|
||||
'ring-header-link-500 hover:ring-header-link-300 text-header-link-500',
|
||||
'dark:ring-header-link-500 dark:hover:ring-header-link-300 dark:text-header-link-500',
|
||||
),
|
||||
}[linkStyle],
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function getHeaderLinkClassName(props: { headerPreset: CustomizationHeaderPreset }) {
|
||||
return tcls(
|
||||
'overflow-hidden',
|
||||
'text-sm lg:text-base',
|
||||
'flex flex-row items-center',
|
||||
'whitespace-nowrap',
|
||||
'hover:text-header-link-400 dark:hover:text-light',
|
||||
'truncate',
|
||||
|
||||
props.headerPreset === CustomizationHeaderPreset.Default
|
||||
? ['text-dark/8', 'dark:text-light/8']
|
||||
: ['text-header-link-500 hover:text-header-link-400'],
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderItemLink(props: HeaderLinkNavItemProps) {
|
||||
const { headerPreset, title, isDropdown, href, ...rest } = props;
|
||||
return (
|
||||
<Link href={href} className={getHeaderLinkClassName({ headerPreset })} {...rest}>
|
||||
{title}
|
||||
{isDropdown ? <DropdownChevron /> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderItemDropdown(
|
||||
props: {
|
||||
headerPreset: CustomizationHeaderPreset;
|
||||
title: string;
|
||||
} & DropdownButtonProps<HTMLElement>,
|
||||
) {
|
||||
const { headerPreset, title, ...rest } = props;
|
||||
return (
|
||||
<span
|
||||
className={tcls(getHeaderLinkClassName({ headerPreset }), 'cursor-default')}
|
||||
{...rest}
|
||||
>
|
||||
{title}
|
||||
<DropdownChevron />
|
||||
</span>
|
||||
);
|
||||
return renderLink({});
|
||||
}
|
||||
|
||||
async function SubHeaderLink(props: {
|
||||
|
||||
@@ -10,20 +10,15 @@ import React from 'react';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Dropdown, DropdownMenu, DropdownMenuItem } from './Dropdown';
|
||||
import { Dropdown, DropdownChevron, DropdownMenu, DropdownMenuItem } from './Dropdown';
|
||||
import styles from './headerLinks.module.css';
|
||||
|
||||
// @TODO replace by api.CustomizationHeaderItem when available
|
||||
type CustomizationHeaderItem = Omit<CustomizationHeaderLink, 'to'> & {
|
||||
to: CustomizationHeaderLink['to'] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dropdown menu for header links hidden at small screen size.
|
||||
*/
|
||||
export function HeaderLinkMore(props: {
|
||||
label: React.ReactNode;
|
||||
links: CustomizationHeaderItem[];
|
||||
links: CustomizationHeaderLink[];
|
||||
context: ContentRefContext;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
}) {
|
||||
@@ -35,15 +30,23 @@ export function HeaderLinkMore(props: {
|
||||
const renderButton = () => (
|
||||
<button
|
||||
className={tcls(
|
||||
'px-1',
|
||||
!isCustomizationDefault
|
||||
? ['text-header-link-500']
|
||||
: ['text-dark/8', 'dark:text-light/8', 'dark:hover:text-light'],
|
||||
'hover:text-header-link-400',
|
||||
isCustomizationDefault
|
||||
? [
|
||||
'text-dark/8',
|
||||
'dark:text-light/8',
|
||||
'hover:text-primary',
|
||||
'dark:hover:text-primary',
|
||||
]
|
||||
: ['text-header-link', 'hover:text-header-link/8'],
|
||||
'flex',
|
||||
'gap-1',
|
||||
'items-center',
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
<Icon icon="ellipsis" className={tcls('opacity-6', 'size-3', 'ms-1')} />
|
||||
<Icon icon="ellipsis" className={tcls('size-4')} />
|
||||
<DropdownChevron />
|
||||
{/* <Icon icon="chevron-down" className={tcls('size-3')} /> */}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -60,10 +63,10 @@ export function HeaderLinkMore(props: {
|
||||
);
|
||||
}
|
||||
|
||||
async function MoreMenuLink(props: { context: ContentRefContext; link: CustomizationHeaderItem }) {
|
||||
async function MoreMenuLink(props: { context: ContentRefContext; link: CustomizationHeaderLink }) {
|
||||
const { context, link } = props;
|
||||
|
||||
const target = link.to ? await resolveContentRef(link.to, context) : null;
|
||||
const target = await resolveContentRef(link.to, context);
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function HeaderLinks({ children }: HeaderLinksProps) {
|
||||
<div
|
||||
className={tcls(
|
||||
styles.containerHeaderlinks,
|
||||
'flex justify-end items-center gap-x-2.5 mr-2.5 lg:gap-x-5 lg:mr-2.5 *:max-w-56 z-20',
|
||||
'w-full flex justify-end items-center gap-x-4 lg:gap-x-8 *:max-w-56 z-20',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useLanguage, tString } from '@/intl/client';
|
||||
@@ -26,7 +27,22 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
<motion.button
|
||||
layout="position"
|
||||
layoutId="searchbox"
|
||||
transition={{
|
||||
layout: {
|
||||
type: 'spring',
|
||||
bounce: 0,
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.02,
|
||||
}}
|
||||
whileTap={{
|
||||
scale: 0.98,
|
||||
}}
|
||||
onClick={onClick}
|
||||
aria-label={tString(language, 'search')}
|
||||
className={tcls(
|
||||
@@ -35,47 +51,57 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
|
||||
'flex-row',
|
||||
'justify-center',
|
||||
'items-center',
|
||||
'px-2',
|
||||
'gap-3',
|
||||
'text-dark/7',
|
||||
'min-h-[2.5rem]',
|
||||
'w-[2.5rem]',
|
||||
'w-full',
|
||||
'p-2',
|
||||
'gap-2',
|
||||
|
||||
'bg-light',
|
||||
'dark:bg-dark',
|
||||
|
||||
'ring-1',
|
||||
'ring-dark/1',
|
||||
'dark:ring-light/2',
|
||||
|
||||
'shadow',
|
||||
'dark:shadow-none',
|
||||
|
||||
'text-dark/6',
|
||||
'dark:text-light/6',
|
||||
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-none',
|
||||
'bg-dark/2',
|
||||
'transition-colors',
|
||||
'transition-opacity',
|
||||
'ease-out',
|
||||
'hover:opacity-8',
|
||||
'ring-1',
|
||||
'ring-inset',
|
||||
'ring-dark/1',
|
||||
|
||||
'contrast-more:ring-dark',
|
||||
'contrast-more:bg-light',
|
||||
'contrast-more:text-dark',
|
||||
'dark:bg-light/1',
|
||||
'dark:ring-light/1',
|
||||
'dark:text-light/7',
|
||||
'contrast-more:dark:ring-light',
|
||||
'contrast-more:dark:bg-dark',
|
||||
'contrast-more:dark:text-light',
|
||||
'[&>p]:hidden',
|
||||
'[&>span]:hidden',
|
||||
|
||||
'transition-[box-shadow,color,background-color]',
|
||||
'hover:shadow-md',
|
||||
'dark:hover:bg-light/1',
|
||||
'hover:ring-dark/2',
|
||||
'hover:text-dark/8',
|
||||
'dark:hover:ring-light/4',
|
||||
'dark:hover:text-light/8',
|
||||
|
||||
'active:shadow-sm',
|
||||
|
||||
// '[&>p]:hidden',
|
||||
// '[&>span]:hidden',
|
||||
'md:justify-start',
|
||||
'md:[&>p]:flex',
|
||||
'md:[&>span]:flex',
|
||||
'flex',
|
||||
// 'md:[&>p]:flex',
|
||||
// 'md:[&>span]:flex',
|
||||
'md:w-full',
|
||||
'md:px-3.5',
|
||||
'text-base',
|
||||
style,
|
||||
)}
|
||||
>
|
||||
<div className={tcls('text-dark/7', 'pt-1.5', 'pb-2', 'dark:text-light/7')}>
|
||||
<Icon icon="magnifying-glass" className={tcls('shrink-0', 'size-4')} />
|
||||
<Icon icon="magnifying-glass" className={tcls('text-dark/8', 'dark:text-light/8', 'shrink-0', 'size-4')} />
|
||||
<div className={tcls('w-full', 'hidden', 'md:block', 'text-left')}>
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
<Shortcut />
|
||||
</button>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,7 +122,7 @@ const Shortcut = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span
|
||||
<div
|
||||
className={tcls(
|
||||
'hidden',
|
||||
'md:inline',
|
||||
@@ -105,11 +131,12 @@ const Shortcut = () => {
|
||||
'text-dark/5',
|
||||
'contrast-more:text-dark',
|
||||
'dark:text-light/5',
|
||||
'whitespace-nowrap',
|
||||
'contrast-more:dark:text-light',
|
||||
`[font-feature-settings:"calt",_"case"]`,
|
||||
)}
|
||||
>
|
||||
{operatingSystem === 'mac' ? '⌘' : 'Ctrl +'} K
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -56,9 +56,9 @@ export function SearchModal(props: SearchModalProps) {
|
||||
};
|
||||
}, [isSearchOpened]);
|
||||
|
||||
if (state === null) {
|
||||
return null;
|
||||
}
|
||||
// if (state === null) {
|
||||
// return null;
|
||||
// }
|
||||
|
||||
const onChangeQuery = (newQuery: SearchState) => {
|
||||
setSearchState(newQuery);
|
||||
@@ -72,62 +72,72 @@ export function SearchModal(props: SearchModalProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="dialog"
|
||||
className={tcls(
|
||||
'flex',
|
||||
'items-start',
|
||||
'justify-center',
|
||||
'fixed',
|
||||
'inset-0',
|
||||
'bg-dark/4',
|
||||
'backdrop-blur-2xl',
|
||||
'opacity-[1]',
|
||||
'z-30',
|
||||
'px-4',
|
||||
'pt-4',
|
||||
'dark:bg-dark/8',
|
||||
'md:pt-[min(8vw,_6rem)]',
|
||||
)}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{askState?.type === 'loading' ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className={tcls(
|
||||
'w-[100vw]',
|
||||
'h-[100vh]',
|
||||
'fixed',
|
||||
'inset-0',
|
||||
'z-10',
|
||||
'pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<LoadingPane
|
||||
gridStyle={['h-[100vh]', 'aspect-auto', 'top-[-30%]']}
|
||||
pulse
|
||||
tile={96}
|
||||
style={['grid']}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<SearchModalBody
|
||||
{...props}
|
||||
state={state}
|
||||
onChangeQuery={onChangeQuery}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<AnimatePresence>
|
||||
{state !== null ? (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
}}
|
||||
role="dialog"
|
||||
className={tcls(
|
||||
'fixed',
|
||||
'inset-0',
|
||||
'bg-dark/4',
|
||||
'backdrop-blur-2xl',
|
||||
'z-30',
|
||||
'px-4',
|
||||
'pt-4',
|
||||
'dark:bg-dark/8',
|
||||
'md:pt-[min(8vh,6rem)]',
|
||||
)}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{askState?.type === 'loading' ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className={tcls(
|
||||
'w-screen',
|
||||
'h-screen',
|
||||
'fixed',
|
||||
'inset-0',
|
||||
'z-10',
|
||||
'pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<LoadingPane
|
||||
gridStyle={['h-screen', 'aspect-auto', 'top-[-30%]']}
|
||||
pulse
|
||||
tile={96}
|
||||
style={['grid']}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<SearchModalBody
|
||||
{...props}
|
||||
state={state}
|
||||
onChangeQuery={onChangeQuery}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,6 +193,18 @@ function SearchModalBody(
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
transition={{
|
||||
default: {
|
||||
duration: 0
|
||||
},
|
||||
layout: {
|
||||
type: 'spring',
|
||||
bounce: 0.15,
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
layoutId="searchbox"
|
||||
role="dialog"
|
||||
aria-label={tString(language, 'search')}
|
||||
className={tcls(
|
||||
@@ -190,8 +212,9 @@ function SearchModalBody(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'bg-white',
|
||||
'max-w-[768px]',
|
||||
'mt-[-1px]',
|
||||
'max-w-prose',
|
||||
'mx-auto',
|
||||
'max-h-[70dvh]',
|
||||
'w-full',
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-sm',
|
||||
@@ -203,14 +226,6 @@ function SearchModalBody(
|
||||
'dark:bg-dark-3',
|
||||
'dark:ring-light/2',
|
||||
)}
|
||||
initial={{
|
||||
scale: 0.95,
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
|
||||
@@ -174,7 +174,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={tcls('max-h-[70vh]', 'overflow-auto', 'relative')}>
|
||||
<div className={tcls('overflow-auto')}>
|
||||
{children}
|
||||
{results.length === 0 ? (
|
||||
<div
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { SiteSection } from '@gitbook/api';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
/**
|
||||
* Icon shown beside a section in the site section tabs.
|
||||
*/
|
||||
export function SectionIcon(props: { icon: IconName; isActive: boolean }) {
|
||||
const { icon, isActive } = props;
|
||||
|
||||
return (
|
||||
<Icon
|
||||
icon={icon}
|
||||
className={tcls(
|
||||
'size-[1em] text-inherit opacity-8',
|
||||
isActive && 'text-inherit opacity-10',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import type { SiteSection } from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import { SiteSection } from '@gitbook/api';
|
||||
import React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
import { SectionIcon } from './SectionIcon';
|
||||
|
||||
/**
|
||||
* A set of navigational tabs representing site sections for multi-section sites
|
||||
@@ -17,7 +14,13 @@ export function SiteSectionTabs(props: {
|
||||
section: SiteSection;
|
||||
index: number;
|
||||
}) {
|
||||
const { list: sections, index: currentIndex } = props;
|
||||
const { list: sections, section: currentSection, index: currentIndex } = props;
|
||||
|
||||
const tabs = sections.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.title,
|
||||
path: section.urls.published ?? '',
|
||||
}));
|
||||
|
||||
const currentTabRef = React.useRef<HTMLAnchorElement>(null);
|
||||
const navRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -40,9 +43,7 @@ export function SiteSectionTabs(props: {
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentIndex >= 0) {
|
||||
updateTabDimensions();
|
||||
}
|
||||
updateTabDimensions();
|
||||
}, [currentIndex, updateTabDimensions]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
@@ -54,11 +55,11 @@ export function SiteSectionTabs(props: {
|
||||
};
|
||||
}, [updateTabDimensions]);
|
||||
|
||||
const opacity = tabDimensions ? 1 : 0.0;
|
||||
const opacity = Boolean(tabDimensions) ? 1 : 0.0;
|
||||
const scale = (tabDimensions?.width ?? 0) * 0.01;
|
||||
const startPos = `${tabDimensions?.left ?? 0}px`;
|
||||
|
||||
return sections.length > 0 ? (
|
||||
return tabs.length > 0 ? (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
ref={navRef}
|
||||
@@ -83,24 +84,15 @@ export function SiteSectionTabs(props: {
|
||||
'md:px-5',
|
||||
)}
|
||||
>
|
||||
{sections.map((section, index) => {
|
||||
const { id, urls, title, icon } = section;
|
||||
const isActive = index === currentIndex;
|
||||
return (
|
||||
<Tab
|
||||
active={isActive}
|
||||
key={id}
|
||||
label={title}
|
||||
href={urls.published ?? ''}
|
||||
ref={isActive ? currentTabRef : null}
|
||||
icon={
|
||||
icon ? (
|
||||
<SectionIcon isActive={isActive} icon={icon as IconName} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{tabs.map((tab, index) => (
|
||||
<Tab
|
||||
active={currentIndex === index}
|
||||
key={index + tab.path}
|
||||
label={tab.label}
|
||||
href={tab.path}
|
||||
ref={currentIndex === index ? currentTabRef : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* A container for a pseudo element for active tab indicator. A container is needed so we can set
|
||||
a relative position without breaking the z-index of other parts of the header. */}
|
||||
@@ -125,7 +117,7 @@ export function SiteSectionTabs(props: {
|
||||
'after:bg-primary',
|
||||
'dark:after:bg-primary-400',
|
||||
)}
|
||||
/>
|
||||
></div>
|
||||
</div>
|
||||
</nav>
|
||||
) : null;
|
||||
@@ -134,26 +126,24 @@ export function SiteSectionTabs(props: {
|
||||
/**
|
||||
* The tab item - a link to a site section
|
||||
*/
|
||||
const Tab = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
{ active: boolean; href: string; icon?: React.ReactNode; label: string }
|
||||
>(function Tab(props, ref) {
|
||||
const { active, href, icon, label } = props;
|
||||
return (
|
||||
<Link
|
||||
className={tcls(
|
||||
'group/tab px-3 py-1 my-2 rounded straight-corners:rounded-none transition-colors',
|
||||
active && 'text-primary dark:text-primary-400',
|
||||
!active &&
|
||||
'text-dark/8 hover:bg-dark/1 hover:text-dark/9 dark:text-light/8 dark:hover:bg-light/2 dark:hover:text-light/9',
|
||||
)}
|
||||
role="tab"
|
||||
href={href}
|
||||
>
|
||||
<span ref={ref} className={tcls('flex gap-2 items-center w-full truncate')}>
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
const Tab = React.forwardRef<HTMLSpanElement, { active: boolean; href: string; label: string }>(
|
||||
function Tab(props, ref) {
|
||||
const { active, href, label } = props;
|
||||
return (
|
||||
<Link
|
||||
className={tcls(
|
||||
'px-3 py-1 my-2 rounded straight-corners:rounded-none transition-colors',
|
||||
active && 'text-primary dark:text-primary-400',
|
||||
!active &&
|
||||
'text-dark/8 hover:bg-dark/1 hover:text-dark/9 dark:text-light/8 dark:hover:bg-light/2 dark:hover:text-light/9',
|
||||
)}
|
||||
role="tab"
|
||||
href={href}
|
||||
>
|
||||
<span ref={ref} className={tcls('inline-flex w-full truncate')}>
|
||||
{label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentPropsWithoutRef, HTMLAttributes } from 'react';
|
||||
|
||||
import { tcls, ClassValue } from '@/lib/tailwind';
|
||||
|
||||
import { Link } from './Link';
|
||||
|
||||
type ButtonProps = {
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
variant?: 'primary' | 'secondary';
|
||||
size?: 'default' | 'medium' | 'small';
|
||||
className?: ClassValue;
|
||||
} & HTMLAttributes<HTMLElement>;
|
||||
};
|
||||
|
||||
export function Button({
|
||||
href,
|
||||
onClick,
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'default',
|
||||
className,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const variantClasses =
|
||||
variant === 'primary'
|
||||
@@ -45,7 +45,7 @@ export function Button({
|
||||
|
||||
const sizes = {
|
||||
default: ['text-base', 'px-4', 'py-2'],
|
||||
medium: ['text-base', 'px-3', 'py-1'],
|
||||
medium: ['text-sm', 'px-3', 'py-1'],
|
||||
small: ['text-xs', 'px-3 py-2'],
|
||||
};
|
||||
|
||||
@@ -69,14 +69,14 @@ export function Button({
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className={domClassName} {...rest}>
|
||||
<Link href={href} className={domClassName}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className={domClassName} {...rest}>
|
||||
<button type="button" onClick={onClick} className={domClassName}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ export const en = {
|
||||
switch_to_light_theme: 'Switch to light theme',
|
||||
switch_to_system_theme: 'Switch to system theme',
|
||||
search: 'Search',
|
||||
search_or_ask: 'Ask or Search',
|
||||
search_or_ask: 'Search or ask...',
|
||||
search_input_placeholder: 'Search content',
|
||||
search_ask_input_placeholder: 'Search content or ask a question',
|
||||
search_no_results: 'No results for "${1}".',
|
||||
|
||||
@@ -98,6 +98,8 @@ export function apiWithToken(apiToken: string): GitBookAPI {
|
||||
const headersList = headers();
|
||||
const apiEndpoint = headersList.get('x-gitbook-api') ?? DEFAULT_API_ENDPOINT;
|
||||
|
||||
console.log("API Endpoint", apiEndpoint);
|
||||
|
||||
const gitbook = new GitBookAPI({
|
||||
authToken: apiToken,
|
||||
endpoint: apiEndpoint,
|
||||
@@ -113,9 +115,12 @@ export function apiWithToken(apiToken: string): GitBookAPI {
|
||||
export function api(): GitBookAPI {
|
||||
const existing = apiSyncStorage.getStore();
|
||||
if (existing) {
|
||||
console.log("Existing", existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
console.log("Bringing up API");
|
||||
|
||||
const headersList = headers();
|
||||
const apiToken = headersList.get('x-gitbook-token');
|
||||
|
||||
@@ -199,6 +204,7 @@ export const getPublishedContentByUrl = cache({
|
||||
visitorAuthToken: string | undefined,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
console.log("Requesting URL", url);
|
||||
try {
|
||||
const response = await api().urls.getPublishedContentByUrl(
|
||||
{
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { memoryCache } from './memory';
|
||||
export const cacheBackends = [
|
||||
// Cache local to the process
|
||||
// (can't be globally purged or shared between processes)
|
||||
memoryCache,
|
||||
// memoryCache,
|
||||
// Cache local to the datacenter
|
||||
// It can't be purged globally but it's faster
|
||||
cloudflareCache,
|
||||
|
||||
@@ -54,8 +54,8 @@ export function getContentSecurityPolicy(scripts: SpaceIntegrationScript[], nonc
|
||||
object-src 'none';
|
||||
base-uri 'self' ${assetsDomain};
|
||||
form-action 'self' ${assetsDomain};
|
||||
frame-ancestors https: ${process.env.NODE_ENV !== 'production' ? 'http:' : ''};
|
||||
`;
|
||||
// frame-ancestors https:;
|
||||
|
||||
const result = scripts
|
||||
.map(({ contentSecurityPolicy }) => contentSecurityPolicy)
|
||||
|
||||
@@ -4,7 +4,7 @@ import fnv1a from '@sindresorhus/fnv1a';
|
||||
|
||||
import { noCacheFetchOptions } from '@/lib/cache/http';
|
||||
|
||||
import { host, rootUrl } from './links';
|
||||
import { rootUrl } from './links';
|
||||
import { getImageAPIUrl } from './urls';
|
||||
|
||||
export interface CloudflareImageJsonFormat {
|
||||
@@ -31,8 +31,6 @@ export interface CloudflareImageOptions {
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export const imagesResizingSignVersion = '2';
|
||||
|
||||
/**
|
||||
* Return true if images resizing is enabled.
|
||||
*/
|
||||
@@ -55,26 +53,17 @@ export function checkIsHttpURL(input: string | URL): boolean {
|
||||
* Check if an image URL is resizable.
|
||||
* Skip it for non-http(s) URLs (data, etc).
|
||||
* Skip it for SVGs.
|
||||
* Skip it for GitBook images (to avoid recursion).
|
||||
*/
|
||||
export function checkIsSizableImageURL(input: string): boolean {
|
||||
if (!URL.canParse(input)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (input.includes('/~gitbook/image')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = new URL(input);
|
||||
if (parsed.pathname.endsWith('.svg')) {
|
||||
return false;
|
||||
}
|
||||
if (!checkIsHttpURL(parsed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return checkIsHttpURL(parsed);
|
||||
}
|
||||
|
||||
interface ResizeImageOptions {
|
||||
@@ -94,7 +83,7 @@ export function getResizedImageURLFactory(
|
||||
return null;
|
||||
}
|
||||
|
||||
const signature = generateSignature(input);
|
||||
const signature = generateSignatureV1(input);
|
||||
|
||||
return (options) => {
|
||||
const url = new URL('/~gitbook/image', rootUrl());
|
||||
@@ -114,7 +103,7 @@ export function getResizedImageURLFactory(
|
||||
}
|
||||
|
||||
url.searchParams.set('sign', signature);
|
||||
url.searchParams.set('sv', imagesResizingSignVersion);
|
||||
url.searchParams.set('sv', '1');
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
@@ -134,9 +123,11 @@ export function getResizedImageURL(input: string, options: ResizeImageOptions):
|
||||
*/
|
||||
export async function verifyImageSignature(
|
||||
input: string,
|
||||
{ signature }: { signature: string },
|
||||
{ signature, version }: { signature: string; version: '1' | '0' },
|
||||
): Promise<boolean> {
|
||||
return generateSignature(input) === signature;
|
||||
const expectedSignature =
|
||||
version === '1' ? generateSignatureV1(input) : await generateSignatureV0(input);
|
||||
return expectedSignature === signature;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,17 +229,26 @@ function stringifyOptions(options: CloudflareImageOptions): string {
|
||||
const fnv1aUtf8Buffer = new Uint8Array(512);
|
||||
|
||||
/**
|
||||
* Generate a signature for an image.
|
||||
* The signature is relative to the current site being rendered to avoid serving images from other sites on the same domain.
|
||||
* New and faster algorithm to generate a signature for an image.
|
||||
* When setting it in a URL, we use version '1' for the 'sv' querystring parameneter
|
||||
* to know that it was the algorithm that was used.
|
||||
*/
|
||||
function generateSignature(input: string) {
|
||||
const hostName = host();
|
||||
const all = [
|
||||
input,
|
||||
hostName, // The hostname is used to avoid serving images from other sites on the same domain
|
||||
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(':');
|
||||
function generateSignatureV1(input: string): string {
|
||||
const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
|
||||
return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial algorithm used to generate a signature for an image. It didn't use any versioning in the URL.
|
||||
* We still need it to validate older signatures that were generated without versioning
|
||||
* but still exist in previously generated and cached content.
|
||||
*/
|
||||
async function generateSignatureV0(input: string): Promise<string> {
|
||||
const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
|
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(all));
|
||||
|
||||
// Convert ArrayBuffer to hex string
|
||||
const hashArray = Array.from(new Uint8Array(hash));
|
||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return hashHex;
|
||||
}
|
||||
|
||||
@@ -501,7 +501,7 @@ async function lookupSiteOrSpaceInMultiIdMode(
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 30,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
|
||||
sameSite: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import plugin from 'tailwindcss/plugin';
|
||||
import { hexToRgb, shadesOfColor } from './src/lib/colors';
|
||||
|
||||
export const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||
export const opacities = [0, 4, 8, 12, 16, 24, 40, 64, 72, 88, 96];
|
||||
export const opacities = [0, 4, 8, 12, 16, 24, 40, 64, 72, 88, 96, 100];
|
||||
|
||||
/**
|
||||
* Generate a Tailwind color shades from a variable.
|
||||
@@ -209,6 +209,10 @@ const config: Config = {
|
||||
thintop: '0px -1px 0px rgba(0, 0, 0, 0.05)',
|
||||
'1xs': '0px 1px 1px rgba(0, 0, 0, 0.09), 0px 3.267px 2.754px rgb(0, 0, 0, 0.05), 0px 6.278px 6.63px rgb(0, 0, 0, 0.05), 0px 14px 22px rgb(0, 0, 0, 0.04)',
|
||||
},
|
||||
scale: {
|
||||
'98': '0.98',
|
||||
'102': '1.02',
|
||||
}
|
||||
},
|
||||
opacity: opacity(),
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@scalar/api-client-react": "1.0.98",
|
||||
"@scalar/api-client-react": "1.0.87",
|
||||
"classnames": "^2.5.1",
|
||||
"flatted": "^3.2.9",
|
||||
"openapi-types": "^12.1.3",
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ScalarApiButton({ method, path }: { method: string; path: string
|
||||
<div className="scalar scalar-activate">
|
||||
<button
|
||||
className="scalar-activate-button"
|
||||
onClick={() => client?.open({ method, path, _source: 'gitbook' })}
|
||||
onClick={() => client?.open({ method, path })}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="12" fill="none">
|
||||
<path
|
||||
|
||||
@@ -79,7 +79,7 @@ export function generateSchemaExample(
|
||||
return 'text';
|
||||
}
|
||||
|
||||
if (schema.type === 'number' || schema.type === 'integer') {
|
||||
if (schema.type === 'number') {
|
||||
return schema.default || 0;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user