Merge remote-tracking branch 'origin/main' into stevenh/synced-blocks-new-api

This commit is contained in:
Steven Hall
2024-03-29 15:19:59 +00:00
30 changed files with 680 additions and 125 deletions
+7 -5
View File
@@ -1,7 +1,3 @@
<!-- <p align="center">
<img alt="GitBook Open Header" src="./assets/gitbook-open-header.png">
</p> -->
<h1 align="center">GitBook</h1>
<p align="center">
@@ -28,8 +24,10 @@
- [Getting Started](#getting-started)
- [Contributing](#contributing)
- [Types of contributions](#types-of-contributions)
- [Types of contributions](#types-of-contributions)
- [Licensing](#license)
- [Acknowledgements](#acknowledgements)
- [Legacy GitBook](#legacy-gitbook-deprecated)
## Getting Started
@@ -120,3 +118,7 @@ GitBook wouldn't be possible without these projects:
- [Bun](https://bun.sh/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Framer Motion](https://www.npmjs.com/package/framer-motion)
## Legacy GitBook (Deprecated)
Our previous version of GitBook and it's CLI tool are now deprecated. You can still view the old repository and it's commits on this [branch](https://github.com/GitbookIO/gitbook/tree/legacy).
BIN
View File
Binary file not shown.
+15 -14
View File
@@ -52,7 +52,6 @@ const testCases: TestsCase[] = [
{
name: 'Search',
url: '?q=',
fullPage: false,
},
{
name: 'Search Results',
@@ -60,7 +59,6 @@ const testCases: TestsCase[] = [
run: async (page) => {
await page.waitForSelector('[data-test="search-results"]');
},
fullPage: false,
},
{
name: 'AI Search',
@@ -77,17 +75,6 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Snyk',
baseUrl: 'https://docs.snyk.io',
tests: [
{
name: 'Home',
url: '',
run: waitForCookiesDialog,
},
],
},
{
name: 'Versioning',
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
@@ -127,6 +114,7 @@ const testCases: TestsCase[] = [
name: 'Images',
url: 'blocks/block-images',
run: waitForCookiesDialog,
fullPage: true,
},
{
name: 'Inline Images',
@@ -152,6 +140,7 @@ const testCases: TestsCase[] = [
name: 'Tables',
url: 'blocks/tables',
run: waitForCookiesDialog,
fullPage: true,
},
{
name: 'Expandables',
@@ -197,6 +186,18 @@ const testCases: TestsCase[] = [
name: 'Math',
url: 'blocks/math',
},
{
name: 'Embeds',
url: 'blocks/embeds',
},
{
name: 'Annotations',
url: 'blocks/annotations',
run: async (page) => {
await page.waitForSelector('[data-testid="annotation-button"]');
await page.click('[data-testid="annotation-button"]');
},
},
],
},
{
@@ -482,7 +483,7 @@ for (const testCase of testCases) {
display: none !important;
}
`,
fullPage: testEntry.fullPage,
fullPage: testEntry.fullPage ?? false,
});
}
});
-1
View File
@@ -47,7 +47,6 @@
"react": "^18",
"react-dom": "^18",
"react-hotkeys-hook": "^4.4.1",
"react-medium-image-zoom": "^5.1.10",
"recoil": "^0.7.7",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
+3 -1
View File
@@ -261,7 +261,9 @@ function getSchemaProperties(schema: OpenAPIV3.SchemaObject): null | OpenAPISche
result.push({
propertyName,
required: schema.required?.includes(propertyName),
required: Array.isArray(schema.required)
? schema.required.includes(propertyName)
: undefined,
schema: propertySchema,
});
});
@@ -14,6 +14,7 @@ export function AnnotationPopover(props: { children: React.ReactNode; body: Reac
<Popover.Root>
<Popover.Trigger asChild>
<button
data-testid="annotation-button"
aria-label={tString(language, 'annotation_button_label')}
className={tcls(
'decoration-dotted',
@@ -44,6 +45,7 @@ export function AnnotationPopover(props: { children: React.ReactNode; body: Reac
'-outline-offset-2',
'outline-2',
'outline-primary/8',
'z-20',
)}
sideOffset={5}
>
@@ -1,6 +1,7 @@
import { DocumentBlockSyncedBlock } from '@gitbook/api';
import { getSyncedBlockContent } from '@/lib/api';
import { resolveContentRefWithFiles } from '@/lib/references';
import { BlockProps } from './Block';
import { Blocks } from './Blocks';
@@ -18,22 +19,34 @@ export async function BlockSyncedBlock(props: BlockProps<DocumentBlockSyncedBloc
return null;
}
const result = await getSyncedBlockContent(
const syncedBlock = await getSyncedBlockContent(
apiToken,
context.contentRefContext.space.organization,
block.data.ref.syncedBlock,
);
if (!result) {
if (!syncedBlock) {
return null;
}
return (
<Blocks
nodes={result.document.nodes}
document={result.document}
nodes={syncedBlock.document.nodes}
document={syncedBlock.document}
ancestorBlocks={[...ancestorBlocks, block]}
context={context}
context={{
...context,
resolveContentRef: async (ref, options) => {
if (!syncedBlock?.files) {
return context.resolveContentRef(ref, options);
}
const result = resolveContentRefWithFiles(syncedBlock.files, ref);
if (result !== undefined) {
return result;
}
return context.resolveContentRef(ref, options);
},
}}
style={style}
/>
);
+4 -1
View File
@@ -19,6 +19,7 @@ export function Caption(
children: React.ReactNode;
document: JSONDocument;
style?: ClassValue;
fit?: boolean;
wrapperStyle?: ClassValue;
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed;
} & DocumentContextProps,
@@ -28,6 +29,7 @@ export function Caption(
document,
block,
context,
fit = false,
wrapperStyle = [
'relative',
'overflow-hidden',
@@ -43,6 +45,7 @@ export function Caption(
'dark:after:border-light/1',
'dark:after:mix-blend-plus-lighter',
'after:pointer-events-none',
fit ? 'w-fit' : null,
],
style,
} = props;
@@ -60,7 +63,7 @@ export function Caption(
return (
<picture className={tcls('relative', style)}>
<div className={tcls(wrapperStyle)}>{children}</div>
<div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div>
<figcaption
className={tcls(
'text-sm',
@@ -1,4 +1,5 @@
import { DocumentBlockCode, JSONDocument } from '@gitbook/api';
import { useId } from 'react';
import { CodeBlock } from './CodeBlock';
@@ -8,8 +9,10 @@ import { CodeBlock } from './CodeBlock';
*/
export function PlainCodeBlock(props: { code: string; syntax: string }) {
const { code, syntax } = props;
const id = useId();
const block: DocumentBlockCode = {
key: id,
object: 'block',
type: 'code',
data: {
@@ -35,6 +35,33 @@ it('should parse plain code', async () => {
]);
});
it('should parse different code in parallel', async () => {
await Promise.all(
['shell', 'scss', 'markdown', 'less', 'scss', 'css', 'scss', 'yaml'].map(async (syntax) =>
highlight({
object: 'block',
type: 'code',
data: {
syntax: syntax,
},
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }],
},
],
},
],
}),
),
);
});
it('should parse a multilines plain code', async () => {
const tokens = await highlight({
object: 'block',
@@ -561,45 +588,30 @@ it('should support multiple code tokens in an annotation', async () => {
type: 'shiki',
token: {
content: 'const',
color: '#000007',
start: 0,
end: 5,
},
},
{
type: 'shiki',
token: {
content: ' ',
color: '#000001',
start: 5,
end: 6,
},
},
{
type: 'shiki',
token: {
content: 'a',
color: '#000004',
start: 6,
end: 7,
},
},
{
type: 'shiki',
token: {
content: ' ',
color: '#000001',
start: 7,
end: 8,
},
},
{
type: 'shiki',
token: {
content: '=',
color: '#000007',
start: 8,
end: 9,
},
},
{
@@ -613,27 +625,18 @@ it('should support multiple code tokens in an annotation', async () => {
type: 'shiki',
token: {
content: 'hello',
color: '#000004',
start: 9,
end: 14,
},
},
{
type: 'shiki',
token: {
content: '.world',
color: '#000009',
start: 14,
end: 20,
},
},
{
type: 'shiki',
token: {
content: '(',
color: '#000001',
start: 20,
end: 21,
},
},
],
@@ -642,9 +645,6 @@ it('should support multiple code tokens in an annotation', async () => {
type: 'shiki',
token: {
content: ');',
color: '#000001',
start: 21,
end: 23,
},
},
],
@@ -9,7 +9,7 @@ import {
// @ts-ignore - onigWasm is a Wasm module
import onigWasm from 'shiki/onig.wasm?module';
import { singleton, singletonMap } from '@/lib/async';
import { asyncMutexFunction, singleton } from '@/lib/async';
import { getNodeText } from '@/lib/document';
import { trace } from '@/lib/tracing';
@@ -314,10 +314,13 @@ const loadHighlighter = singleton(async () => {
});
});
const loadHighlighterLanguage = singletonMap(async (lang: keyof typeof bundledLanguages) => {
const highlighter = await loadHighlighter();
await trace(
`highlighting.loadLanguage(${lang})`,
async () => await highlighter.loadLanguage(lang),
);
});
const loadLanguagesMutex = asyncMutexFunction();
async function loadHighlighterLanguage(lang: keyof typeof bundledLanguages) {
await loadLanguagesMutex.runBlocking(async () => {
const highlighter = await loadHighlighter();
await trace(
`highlighting.loadLanguage(${lang})`,
async () => await highlighter.loadLanguage(lang),
);
});
}
+1 -1
View File
@@ -19,7 +19,7 @@ export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
sources={{
light: {
src: resolved.href,
size: resolved.fileDimensions,
size: resolved.file?.dimensions,
},
}}
alt="Drawing"
+3 -8
View File
@@ -4,7 +4,6 @@ import IconImage from '@geist-ui/icons/image';
import IconPaperClip from '@geist-ui/icons/paperclip';
import { DocumentBlockFile } from '@gitbook/api';
import { getRevisionFile } from '@/lib/api';
import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
@@ -12,13 +11,9 @@ import { BlockProps } from './Block';
export async function File(props: BlockProps<DocumentBlockFile>) {
const { block, context, style } = props;
const file = context.content
? await getRevisionFile(
context.content.spaceId,
context.content.revisionId,
block.data.ref.file,
)
: null;
const contentRef = await context.resolveContentRef(block.data.ref);
const file = contentRef?.file;
if (!file) {
return null;
}
+3 -3
View File
@@ -82,19 +82,19 @@ async function ImageBlock(props: {
}
return (
<Caption {...props}>
<Caption {...props} fit>
<Image
alt={block.data.alt ?? ''}
sizes={imageBlockSizes}
sources={{
light: {
src: src.href,
size: src.fileDimensions,
size: src.file?.dimensions,
},
dark: darkSrc
? {
src: darkSrc.href,
size: darkSrc.fileDimensions,
size: darkSrc.file?.dimensions,
}
: null,
}}
+3 -3
View File
@@ -32,12 +32,12 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
sources={{
light: {
src: src.href,
size: src.fileDimensions,
size: src.file?.dimensions,
},
dark: darkSrc
? {
src: darkSrc.href,
size: darkSrc.fileDimensions,
size: darkSrc.file?.dimensions,
}
: null,
}}
@@ -55,7 +55,7 @@ async function getImageSizes(size: 'original' | 'line', src: ResolvedContentRef)
switch (size) {
case 'line': {
const imageSize =
src.fileDimensions ??
src.file?.dimensions ??
(await getImageSize(src.href, {
dpr: 3,
}));
@@ -23,7 +23,7 @@
/** Method tag */
.openapi-method {
@apply rounded font-mono font-bold text-[11.5px] px-1 py-0.5 mr-2 text-dark-1/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-dark-1/1 dark:ring-light-1/1;
@apply rounded font-mono font-bold text-[11.5px] px-1 py-0.5 mr-2 text-dark-1/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-dark-1/1 dark:ring-light-1/1 whitespace-nowrap;
}
.openapi-method-get {
@@ -106,8 +106,14 @@
@apply mt-3;
}
.openapi-schema-name {
/* To make double click on the property name select only the name,
we disable selection on the parent and re-enable it on the children. */
@apply select-none;
}
.openapi-schema-propertyname {
@apply font-medium text-base text-dark/10 dark:text-light/10;
@apply select-all font-medium text-base text-dark/10 dark:text-light/10;
}
.openapi-schema-required {
@@ -115,7 +121,7 @@
}
.openapi-schema-type {
@apply font-mono ml-3 text-base text-dark-4/8 dark:text-light-4;
@apply font-mono ml-3 text-base text-dark-4/8 dark:text-light-4 select-text;
}
.openapi-schema-type:only-child {
@@ -52,7 +52,7 @@ export async function RecordCard(
sources={{
light: {
src: cover.href,
size: cover.fileDimensions,
size: cover.file?.dimensions,
},
}}
sizes={[
@@ -61,6 +61,7 @@ export async function RecordCard(
},
]}
className={tcls(
'min-w-0',
'w-full',
'h-full',
'object-cover',
@@ -73,10 +74,12 @@ export async function RecordCard(
) : null}
<div
className={tcls(
'min-w-0',
'w-full',
'flex',
'flex-col',
'place-self-start',
'gap-3',
'p-4',
'text-sm',
target
@@ -172,7 +172,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
case 'select': {
return (
<Tag className={tcls()}>
<span className={tcls('inline-flex', 'gap-2')}>
<span className={tcls('inline-flex', 'gap-2', 'flex-wrap')}>
{(value as string[]).map((selectId) => {
const option = definition.options.find(
(option) => option.value === selectId,
+1 -2
View File
@@ -5,7 +5,6 @@ import { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import defaultPageCover from './default-page-cover.svg';
import { PAGE_COVER_HEIGHT } from '../layout';
const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 };
@@ -43,7 +42,7 @@ export async function PageCover(props: {
light: resolved
? {
src: resolved.href,
size: resolved.fileDimensions,
size: resolved.file?.dimensions,
}
: {
src: defaultPageCover.src,
-2
View File
@@ -45,12 +45,10 @@ export function SearchModal(props: SearchModalProps) {
const isSearchOpened = state !== null;
React.useEffect(() => {
if (isSearchOpened) {
document.body.classList.add('search-open');
document.body.style.overflow = 'hidden';
}
return () => {
document.body.classList.remove('search-open');
document.body.style.overflow = 'auto';
};
}, [isSearchOpened]);
+14 -12
View File
@@ -5,7 +5,7 @@ import { checkIsHttpURL, getImageSize, getResizedImageURL } from '@/lib/images';
import { ClassValue, tcls } from '@/lib/tailwind';
import { PolymorphicComponentProp } from './types';
import { Zoom } from './Zoom';
import { ZoomImage } from './ZoomImage';
export type ImageSize = { width: number; height: number };
@@ -87,6 +87,10 @@ interface ImageCommonProps {
inlineStyle?: React.CSSProperties;
}
interface ImgDOMPropsWithSrc extends React.ComponentPropsWithoutRef<'img'> {
src: string;
}
/**
* Render an image that will be swapped depending on the theme.
* We don't use the `next/image` component because we need to load images from external sources,
@@ -271,16 +275,14 @@ async function ImagePicture(
});
}
const img = (
<img
alt={alt}
style={style}
loading={loading}
fetchPriority={fetchPriority}
{...rest}
{...attrs}
/>
);
const imgProps: ImgDOMPropsWithSrc = {
alt,
style,
loading,
fetchPriority,
...rest,
...attrs,
};
return zoom ? <Zoom wrapElement={inline ? 'span' : 'div'}>{img}</Zoom> : img;
return zoom ? <ZoomImage {...imgProps} /> : <img {...imgProps} alt={imgProps.alt ?? ''} />;
}
-3
View File
@@ -1,3 +0,0 @@
html.dark [data-rmiz-modal-overlay='visible'] {
background-color: rgb(var(--dark-base));
}
-13
View File
@@ -1,13 +0,0 @@
'use client';
import ReactZoom, { UncontrolledProps } from 'react-medium-image-zoom';
import 'react-medium-image-zoom/dist/styles.css';
import './Zoom.css';
/**
* Client component to zoom on an image.
* See https://github.com/rpearce/react-medium-image-zoom
*/
export function Zoom(props: UncontrolledProps) {
return <ReactZoom {...props} />;
}
+19
View File
@@ -0,0 +1,19 @@
html:has(.zoomModal) {
overflow: hidden;
}
.zoomImg {
cursor: zoom-in;
}
.zoomImageActive {
view-transition-name: zoom-image;
}
.zoomModal {
}
.zoomModal img {
view-transition-name: zoom-image;
cursor: zoom-out;
}
+291
View File
@@ -0,0 +1,291 @@
'use client';
import IconMinimize from '@geist-ui/icons/minimize';
import classNames from 'classnames';
import React from 'react';
import ReactDOM from 'react-dom';
import { tcls } from '@/lib/tailwind';
import styles from './ZoomImage.module.css';
/**
* Replacement for an <img> tag that allows zooming.
* The implementation uses the experimental View Transition API in Chrome for a smooth transition.
*/
export function ZoomImage(
props: React.ComponentPropsWithoutRef<'img'> & {
src: string;
},
) {
const { src, alt, width } = props;
const imgRef = React.useRef<HTMLImageElement>(null);
const [zoomable, setZoomable] = React.useState(false);
const [active, setActive] = React.useState(false);
const [opened, setOpened] = React.useState(false);
const [placeholderRect, setPlaceholderRect] = React.useState<DOMRect | null>(null);
// Only allow zooming when image will not actually be larger and on mobile
React.useEffect(() => {
if (isTouchDevice()) {
return;
}
const imageWidth = typeof width === 'number' ? width : 0;
let viewWidth = 0;
const mediaQueryList = window.matchMedia('(min-width: 768px)');
const resizeObserver =
typeof ResizeObserver !== 'undefined'
? new ResizeObserver((entries) => {
const imgEntry = entries[0];
// Since the image is removed from the DOM when the modal is opened,
// We only care when the size is defined.
if (imgEntry && imgEntry.contentRect.width !== 0) {
viewWidth = entries[0]?.contentRect.width;
setPlaceholderRect(entries[0].contentRect);
onChange();
}
})
: null;
const onChange = () => {
if (!mediaQueryList.matches) {
// Don't allow zooming on mobile
setZoomable(false);
} else if (resizeObserver && imageWidth && viewWidth && imageWidth <= viewWidth) {
// Image can't be zoomed if it's already rendered as it's largest size
setZoomable(false);
} else {
setZoomable(true);
}
};
mediaQueryList.addEventListener('change', onChange);
if (imgRef.current) {
resizeObserver?.observe(imgRef.current);
}
if (!resizeObserver) {
// When resizeObserver is available, it'll take care of calling the changelog as soon as the element is observed
onChange();
}
return () => {
resizeObserver?.disconnect();
mediaQueryList.removeEventListener('change', onChange);
};
}, [imgRef, width]);
// Preload the image that will be displayed in the modal
if (zoomable) {
ReactDOM.preload(src, {
as: 'image',
});
}
const preloadImage = React.useCallback(
(onLoad?: () => void) => {
const image = new Image();
image.src = src;
image.onload = () => {
onLoad?.();
};
},
[src],
);
// When closing the modal, animate the transition back to the original image
const onClose = React.useCallback(() => {
startViewTransition(
() => {
setOpened(false);
},
() => {
setActive(false);
},
);
}, []);
return (
<>
{opened ? (
<>
{placeholderRect ? (
// Placeholder to keep the layout stable when the image is removed from the DOM
<span
style={{
display: 'block',
width: placeholderRect.width,
height: placeholderRect.height,
}}
/>
) : null}
{ReactDOM.createPortal(
<ZoomImageModal
src={src}
crossOrigin={props.crossOrigin}
alt={alt ?? ''}
onClose={onClose}
/>,
document.body,
)}
</>
) : (
// When zooming, remove the image from the DOM to let the browser animates it with View Transition.
<img
ref={imgRef}
{...props}
alt={alt ?? ''}
onMouseEnter={() => {
if (zoomable) {
preloadImage();
}
}}
onClick={() => {
if (!zoomable) {
return;
}
// Preload the image before opening the modal to ensure the animation is smooth
preloadImage(() => {
const change = () => {
setOpened(true);
};
ReactDOM.flushSync(() => setActive(true));
startViewTransition(change);
});
}}
className={classNames(
props.className,
zoomable ? styles.zoomImg : null,
active ? styles.zoomImageActive : null,
)}
/>
)}
</>
);
}
function ZoomImageModal(props: {
src: string;
alt: string;
crossOrigin: React.ComponentPropsWithoutRef<'img'>['crossOrigin'];
onClose: () => void;
}) {
const { src, alt, crossOrigin, onClose } = props;
const buttonRef = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
React.useEffect(() => {
buttonRef.current?.focus();
}, []);
return (
<div
className={classNames(
styles.zoomModal,
tcls(
'fixed',
'inset-0',
'z-50',
'flex',
'items-center',
'justify-center',
'bg-light',
'dark:bg-dark',
'p-8',
),
)}
onClick={onClose}
>
<img
src={src}
alt={alt}
crossOrigin={crossOrigin}
className={tcls(
'max-w-full',
'max-h-full',
'object-contain',
'bg-light',
'dark:bg-dark',
)}
/>
<button
ref={buttonRef}
className={tcls(
'absolute',
'top-5',
'right-5',
'flex',
'flex-row',
'items-center',
'justify-center',
'text-sm',
'text-dark/6',
'dark:text-light/5',
'hover:text-primary',
'p-4',
'dark:text-light/5',
'rounded-full',
'bg-white',
'dark:bg-dark/3',
'shadow-sm',
'hover:shadow-md',
'border-slate-300',
'dark:border-dark/2',
'border',
)}
onClick={onClose}
>
<IconMinimize />
</button>
</div>
);
}
function startViewTransition(callback: () => void, onEnd?: () => void) {
// @ts-ignore
if (document.startViewTransition) {
// @ts-ignore
const transition = document.startViewTransition(() => {
ReactDOM.flushSync(() => callback());
});
transition.finished.then(() => {
if (onEnd) {
onEnd();
}
});
} else {
callback();
onEnd?.();
}
}
function isTouchDevice(): boolean {
return (
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
// @ts-ignore
navigator.msMaxTouchPoints > 0
);
}
+86 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { race } from './async';
import { asyncMutexFunction, race } from './async';
import { flushWaitUntil } from './waitUntil';
describe('race', () => {
@@ -380,3 +380,88 @@ describe('race', () => {
});
});
});
describe('asyncMutexFunction', () => {
describe('self', () => {
it('should run only once', async () => {
let running = 0;
const fn = async () => {
running += 1;
await new Promise((resolve) => setTimeout(resolve, 10));
};
const mutexFn = asyncMutexFunction();
await Promise.all([
mutexFn(fn),
mutexFn(fn),
mutexFn(fn),
mutexFn(fn),
mutexFn(fn),
mutexFn(fn),
]);
expect(running).toBe(1);
});
});
describe('runBlocking', () => {
it('should run only one function at a time', async () => {
let running = 0;
let maxRunning = 0;
const fn = async () => {
running += 1;
maxRunning = Math.max(running, maxRunning);
await new Promise((resolve) => setTimeout(resolve, 10));
running -= 1;
};
const mutexFn = asyncMutexFunction();
await Promise.all([
mutexFn.runBlocking(fn),
mutexFn.runBlocking(fn),
mutexFn.runBlocking(fn),
mutexFn.runBlocking(fn),
mutexFn.runBlocking(fn),
mutexFn.runBlocking(fn),
]);
expect(maxRunning).toBe(1);
});
});
describe('runAfter', () => {
it('should run only one function at a time', async () => {
let running = 0;
let maxRunning = 0;
const fn = async () => {
running += 1;
maxRunning = Math.max(running, maxRunning);
await new Promise((resolve) => setTimeout(resolve, 10));
running -= 1;
};
const mutexFn = asyncMutexFunction();
await Promise.all([
mutexFn.runAfter(fn),
mutexFn.runAfter(fn),
mutexFn.runAfter(fn),
mutexFn.runAfter(fn),
mutexFn.runAfter(fn),
mutexFn.runAfter(fn),
]);
expect(maxRunning).toBe(1);
});
});
});
+109
View File
@@ -361,3 +361,112 @@ export function batch<Args extends any[], R>(
});
};
}
type MutexOperationOptions = {
/**
* If true, fail the operation if a pending operation on the mutex fails.
* Defaults to true.
*/
failOnMutexError?: boolean;
};
export type AsyncMutexFunction<T> = ((fn: () => Promise<T>) => Promise<T>) & {
/**
* Wait for a pending operation to complete.
*/
wait: () => Promise<unknown>;
/**
* Execute a function after the previous operation completes.
*/
runAfter: (fn: () => Promise<T>, options?: MutexOperationOptions) => Promise<T>;
/**
* Execute a function that blocks the mutex, but does not influence the return value of the mutex.
*/
runBlocking: <ReturnType>(
fn: () => Promise<ReturnType>,
options?: MutexOperationOptions,
) => Promise<ReturnType>;
};
/**
* Creates a function that will only call the given function once at a time.
*/
export function asyncMutexFunction<T>(): AsyncMutexFunction<T> {
let pending:
| undefined
| { kind: 'value'; promise: Promise<T> }
| { kind: 'blocking'; promise: Promise<unknown> };
const mutex: AsyncMutexFunction<T> = async (fn) => {
if (pending?.kind === 'value') {
return pending.promise;
}
while (pending) {
await pending.promise;
}
const promise = fn();
pending = { kind: 'value', promise };
try {
const result = await promise;
return result;
} finally {
pending = undefined;
}
};
mutex.wait = async () => {
return pending?.promise;
};
mutex.runBlocking = async (fn, options) => {
const failOnMutexError = options?.failOnMutexError ?? true;
while (pending) {
try {
await pending.promise;
} catch (err) {
if (failOnMutexError) {
throw err;
}
}
}
const promise = fn();
pending = { kind: 'blocking', promise };
try {
const result = await promise;
return result;
} finally {
pending = undefined;
}
};
mutex.runAfter = async (fn, options) => {
const failOnMutexError = options?.failOnMutexError ?? true;
while (pending) {
try {
await pending.promise;
} catch (err) {
if (failOnMutexError) {
throw err;
}
}
}
const promise = fn();
pending = { kind: 'value', promise };
try {
const result = await pending.promise;
return result;
} finally {
pending = undefined;
}
};
return mutex;
}
+23 -4
View File
@@ -1,4 +1,4 @@
import { ContentRef, Revision, RevisionPageDocument, Space } from '@gitbook/api';
import { ContentRef, Revision, RevisionFile, RevisionPageDocument, Space } from '@gitbook/api';
import assertNever from 'assert-never';
import {
@@ -24,8 +24,8 @@ export interface ResolvedContentRef {
href: string;
/** True if the content ref is active */
active: boolean;
/** Image size, if the reference is a image file */
fileDimensions?: { width: number; height: number };
/** File, if the reference is a file */
file?: RevisionFile;
}
export interface ContentRefContext extends PageHrefContext {
@@ -90,7 +90,7 @@ export async function resolveContentRef(
href: file.downloadURL,
text: file.name,
active: false,
fileDimensions: file.dimensions,
file,
};
} else {
return null;
@@ -251,3 +251,22 @@ async function resolveContentRefInSpace(spaceId: string, contentRef: ContentRef)
baseUrl,
});
}
export function resolveContentRefWithFiles(
files: RevisionFile[],
contentRef: ContentRef,
): ResolvedContentRef | null | undefined {
if (contentRef.kind === 'file') {
const file = files.find((file) => file.id === contentRef.file);
if (file) {
return {
href: file.downloadURL,
text: file.name,
active: false,
file,
};
}
return null;
}
return undefined;
}
+23 -1
View File
@@ -569,7 +569,29 @@ async function lookupSpaceByAPI(
}
if ('redirect' in data) {
if (alternative.url === url.toString()) {
if (alternative.primary) {
// Append the path to the redirect URL
// because we might have matched a shorter path and the redirect is relative to it
if (alternative.extraPath) {
if (data.target === 'content') {
const redirect = new URL(data.redirect);
redirect.pathname = joinPath(redirect.pathname, alternative.extraPath);
data.redirect = redirect.toString();
} else {
const redirect = new URL(data.redirect);
if (redirect.searchParams.has('location')) {
redirect.searchParams.set(
'location',
joinPath(
redirect.searchParams.get('location') ?? '',
alternative.extraPath,
),
);
data.redirect = redirect.toString();
}
}
}
return data;
}
-5
View File
@@ -199,11 +199,6 @@ const config: Config = {
*/
addVariant('navigation-open', 'body.navigation-open &');
/**
* Variant when the search overlay is open.
*/
addVariant('search-open', 'body.search-open &');
/**
* Variant when a header is displayed.
*/