diff --git a/README.md b/README.md index 20a5005fb..f011225fa 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,3 @@ - -

GitBook

@@ -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). diff --git a/bun.lockb b/bun.lockb index f77d3f705..7937bcd8e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/e2e/pages.spec.ts b/e2e/pages.spec.ts index e71b6f80c..f032436f9 100644 --- a/e2e/pages.spec.ts +++ b/e2e/pages.spec.ts @@ -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, }); } }); diff --git a/package.json b/package.json index 629001e2a..403bacfed 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/react-openapi/src/OpenAPISchema.tsx b/packages/react-openapi/src/OpenAPISchema.tsx index 8056c15e7..87f0ddb0b 100644 --- a/packages/react-openapi/src/OpenAPISchema.tsx +++ b/packages/react-openapi/src/OpenAPISchema.tsx @@ -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, }); }); diff --git a/src/components/DocumentView/Annotation/AnnotationPopover.tsx b/src/components/DocumentView/Annotation/AnnotationPopover.tsx index b2d17a7e3..3b6e776f0 100644 --- a/src/components/DocumentView/Annotation/AnnotationPopover.tsx +++ b/src/components/DocumentView/Annotation/AnnotationPopover.tsx @@ -14,6 +14,7 @@ export function AnnotationPopover(props: { children: React.ReactNode; body: Reac + + ); +} + +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 + ); +} diff --git a/src/lib/async.test.ts b/src/lib/async.test.ts index ed7c1cd13..5891f69f9 100644 --- a/src/lib/async.test.ts +++ b/src/lib/async.test.ts @@ -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); + }); + }); +}); diff --git a/src/lib/async.ts b/src/lib/async.ts index 26b875f28..e99cb26f7 100644 --- a/src/lib/async.ts +++ b/src/lib/async.ts @@ -361,3 +361,112 @@ export function batch( }); }; } + +type MutexOperationOptions = { + /** + * If true, fail the operation if a pending operation on the mutex fails. + * Defaults to true. + */ + failOnMutexError?: boolean; +}; + +export type AsyncMutexFunction = ((fn: () => Promise) => Promise) & { + /** + * Wait for a pending operation to complete. + */ + wait: () => Promise; + + /** + * Execute a function after the previous operation completes. + */ + runAfter: (fn: () => Promise, options?: MutexOperationOptions) => Promise; + + /** + * Execute a function that blocks the mutex, but does not influence the return value of the mutex. + */ + runBlocking: ( + fn: () => Promise, + options?: MutexOperationOptions, + ) => Promise; +}; + +/** + * Creates a function that will only call the given function once at a time. + */ +export function asyncMutexFunction(): AsyncMutexFunction { + let pending: + | undefined + | { kind: 'value'; promise: Promise } + | { kind: 'blocking'; promise: Promise }; + + const mutex: AsyncMutexFunction = 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; +} diff --git a/src/lib/references.ts b/src/lib/references.ts index 8c5a8bdaf..b7f6771f9 100644 --- a/src/lib/references.ts +++ b/src/lib/references.ts @@ -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; +} diff --git a/src/middleware.ts b/src/middleware.ts index c9d6e5164..55470c74f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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; } diff --git a/tailwind.config.ts b/tailwind.config.ts index 525e4e743..9c6b8e92f 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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. */