mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-15 15:15:55 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1de0adb6a | |||
| 408540ae74 | |||
| 73892f4fc2 | |||
| 636b868bf1 | |||
| b144368e3f | |||
| 683b4193d1 | |||
| f4c953665a | |||
| 0f990c772a | |||
| 0bf985af61 | |||
| 42473611d3 | |||
| 2c437f7d94 | |||
| aa8c49eb0c | |||
| b32e40c72a | |||
| 2ff7ed1b52 | |||
| bfbed1a69b | |||
| ef9d012be9 | |||
| f12a2155c0 | |||
| 43f696c4a8 | |||
| 4c19014dcd | |||
| 0e7f9d6778 | |||
| 00a0bf3c1b | |||
| e9149036f6 | |||
| b6a9967959 | |||
| 8bbc3caf3e | |||
| 237b703a6c | |||
| cf3045a718 | |||
| a679e7283f | |||
| 13c75341e2 | |||
| cb9a15339d |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
---
|
||||
|
||||
Add Python support in Code Samples
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Use ellipsis and fix icon color for more links in the header on small screen
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/icons': patch
|
||||
---
|
||||
|
||||
Fix first run of the package in development mode
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/cache-do': minor
|
||||
---
|
||||
|
||||
First version of a new cache backend powered by Cloudflare Durable Objects
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Add support for Norwegian language
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Optimize layout shift while transitioning between pages with full width blocks (ex: OpenAPI blocks)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Persist state of tabs and dynamically sync them based on title
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Ensure "Sponsored via GitBook" can be translated in all languages
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Use new cache backend, powered by Durable Objects, alongside the existing ones (KV, etc).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
---
|
||||
|
||||
Add required query parameters to the code sample
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
---
|
||||
|
||||
Display pattern if available in parmas in OpenAPI block
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Synchronize response and response example tabs
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Show definition title when visible in cards
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Fix crash when `example` is undefined for a response
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Render mandatory headers in code sample
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Prevent search indexation for pages where it's configured as disabled
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix table of contents being visible on mobile when disabled at the page level
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Don't show hidden pages in the empty state of a page
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Fix linking to a tab itself
|
||||
+21
-7
@@ -1,6 +1,8 @@
|
||||
# Welcome to GitBook's contributing guide!
|
||||
|
||||
Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team.In this guide, you'll learn the different ways you can contribute.
|
||||
> _For help, support, feature requests, and product questions - head to our [GitHub Community](https://github.com/orgs/GitbookIO/discussions) 🤖_
|
||||
|
||||
Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team. In this guide, you'll learn the different ways you can contribute.
|
||||
|
||||
## Types of Contributions
|
||||
|
||||
@@ -45,13 +47,25 @@ Any contribution you make can be made to the code located in this repository. In
|
||||
|
||||
- [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them.
|
||||
|
||||
##### GitHub Codespaces:
|
||||
|
||||
- [Fork, edit, and preview](https://docs.github.com/en/free-pro-team@latest/github/developing-online-with-codespaces/creating-a-codespace) using [GitHub Codespaces](https://github.com/features/codespaces) without having to install and run the project locally.
|
||||
|
||||
#### 2. Create a working branch and start with your changes
|
||||
|
||||
After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of. After creating the branch, you can start making changes!
|
||||
After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of.
|
||||
|
||||
#### 3. Install dependencies and run the project locally
|
||||
|
||||
GitBook uses [Bun](https://bun.sh/) to run the project. Make sure you're using the specified version of `node` before running any of the development commands to ensure a smooth development experience.
|
||||
|
||||
You can easily do this by running the command `nvm use`.
|
||||
|
||||
To start your local version of GitBook, run the command `bun dev`.
|
||||
|
||||
#### 4. Preview your changes
|
||||
|
||||
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
|
||||
|
||||
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/docs.gitbook.com` after running the development server.
|
||||
|
||||
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://docs.gitbook.com/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
|
||||
|
||||
### Commit your update
|
||||
|
||||
@@ -71,4 +85,4 @@ When you're finished with the changes, [create a pull request](https://docs.gith
|
||||
|
||||
### Your PR is merged
|
||||
|
||||
Congratulations 🎉Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository.
|
||||
Congratulations 🎉 Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository.
|
||||
|
||||
@@ -93,6 +93,12 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-test-results
|
||||
path: packages/gitbook/test-results/
|
||||
retention-days: 3
|
||||
pagespeed-testing:
|
||||
runs-on: ubuntu-latest
|
||||
name: PageSpeed Testing
|
||||
|
||||
@@ -35,3 +35,26 @@ jobs:
|
||||
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
release-preview:
|
||||
# For now it releases the cache-do to both preview and production
|
||||
# Once we changed to deploy the app only on release, we should change `release:preview` in `cache-do`
|
||||
name: Release Preview
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Release preview packages
|
||||
run: bun run release:preview
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<p align="center">Welcome to GitBook, the platform for managing technical knowledge for teams.</p>
|
||||
|
||||
<p align="center">This repository contains the open-source code used to render GitBook's published content.</p>
|
||||
<p align="center">This repository contains the open source code used to render GitBook's published content.</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="GitBook Open Published Site" src="./assets/published-site.png">
|
||||
@@ -46,19 +46,28 @@ To run a local version of this project, please follow these simple steps.
|
||||
git clone https://github.com/gitbookIO/gitbook.git
|
||||
```
|
||||
|
||||
2. Install the project's dependencies through Bun.
|
||||
2. Ensure you are using the project's version of `node`. Running `nvm use` will change your local version to the correct one.
|
||||
|
||||
3. Install the project's dependencies through Bun.
|
||||
|
||||
```
|
||||
bun install
|
||||
```
|
||||
|
||||
3. Start your local development server.
|
||||
4. Start your local development server.
|
||||
|
||||
```
|
||||
bun dev
|
||||
```
|
||||
|
||||
Then open the space in your web browser, using http://localhost:3000/<host>/<path> (example: http://localhost:3000/docs.gitbook.com).
|
||||
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
||||
|
||||
examples:
|
||||
|
||||
- http://localhost:3000/docs.gitbook.com
|
||||
- http://localhost:3000/open-source.gitbook.io/midjourney
|
||||
|
||||
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
|
||||
|
||||
### Other development commands
|
||||
|
||||
@@ -71,7 +80,7 @@ All pull-requests will be tested against both visual and performances testing to
|
||||
|
||||
## Contributing
|
||||
|
||||
GitBook's rendering engine is fully open-source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
|
||||
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
|
||||
|
||||
### Types of contributions
|
||||
|
||||
@@ -79,7 +88,7 @@ We encourage you to contribute to GitBook to help us build the best tool for doc
|
||||
|
||||
#### Translations
|
||||
|
||||
The GitBook UI is rendered using a set of translation files found in [`src/intl/translations`](/src/intl/translations/). We welcome all additional translations for the UI.
|
||||
The GitBook UI is rendered using a set of translation files found in [`packages/gitbook/src/intl/translations`](/packages/gitbook/src/intl/translations/). We welcome all additional translations for the UI.
|
||||
|
||||
#### Bugs
|
||||
|
||||
@@ -90,7 +99,7 @@ Encounter a bug or find an issue you'd like to fix? Helping us fix issues relate
|
||||
> [!WARNING]
|
||||
> While it is possible to self-host this project, we do not recommend this unless you are certain this option fits your need.
|
||||
>
|
||||
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](/.github/CONTRIBUTING.md) to get started._
|
||||
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.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, maintenance, or updates to forked and self-hosted instances of this project.
|
||||
>
|
||||
|
||||
+4
-5
@@ -16,7 +16,9 @@
|
||||
"e2e": "turbo run e2e",
|
||||
"changeset": "changeset",
|
||||
"release": "turbo run release && changeset publish",
|
||||
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local"
|
||||
"release:preview": "turbo run release:preview",
|
||||
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
|
||||
"clean": "turbo run clean"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -24,12 +26,9 @@
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"prettier": "^3.0.3",
|
||||
"turbo": "^2.0.9"
|
||||
"turbo": "^2.1.2"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@vercel/next@4.3.6": "patches/@vercel%2Fnext@4.3.6.patch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scalar/api-client-react": "0.3.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.wrangler
|
||||
worker-configuration.d.ts
|
||||
dist/
|
||||
@@ -0,0 +1,22 @@
|
||||
# `@gitbook/cache-do`
|
||||
|
||||
Cache backend, powered by Cloudflare Durable Objects. The cache is optimized for GitBook use-cases.
|
||||
|
||||
### Performances
|
||||
|
||||
The cache backend is optimized for performances by being distributed and accessible close to the worker locations that are reading it.
|
||||
|
||||
### Geo-distribution
|
||||
|
||||
To achieve a good balance between **performances** and **consistency**, cache objects are distributed over 7 locations, representing continents.
|
||||
|
||||
It makes it possible to purge all 7 locations in one go and achieve fast consistency.
|
||||
|
||||
### Concepts
|
||||
|
||||
**Cache tag**: unique tag in the cache environment. A cache tag groups multiple keys that should be purged together in one operation.
|
||||
Cache tags should not contain a large set of unique keys. Exceeding thousands could lead to performances or reliability issues.
|
||||
|
||||
**Cache key**: unique key in the cache environment. Each key should be assigned to a `tag`.
|
||||
|
||||
**Location**: cache is distributed over 7 unique locations, one for each continent.
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@gitbook/cache-do",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./api": {
|
||||
"types": "./dist/api.d.ts",
|
||||
"development": "./src/api.ts",
|
||||
"default": "./dist/api.js"
|
||||
}
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||
"lru_map": "^0.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"wrangler": "^3.78.7"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "wrangler types --experimental-include-runtime",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w",
|
||||
"release": "wrangler deploy",
|
||||
"release:preview": "wrangler deploy && wrangler deploy --env preview"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"bin",
|
||||
"data",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { encode, decode } from '@msgpack/msgpack';
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
import { LRUMap } from 'lru_map';
|
||||
|
||||
export interface CacheObjectDescriptor {
|
||||
get: <Value = unknown>(key: string) => Promise<Value | undefined>;
|
||||
set: <Value = unknown>(key: string, value: Value, expiresAt: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value stored in a chunked binary msgpack format.
|
||||
* Stored under the key `prop.${key}.${index}`.
|
||||
*/
|
||||
interface CacheObjectProp<Value = unknown> {
|
||||
value: Value;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expiration clock stored under the key `exp.${expiresAt}.${key}`.
|
||||
*/
|
||||
interface CacheObjectExp {
|
||||
/** Key of the property */
|
||||
k: string;
|
||||
/** Number of chunks */
|
||||
c: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable Object class being deployed as a distributed cache.
|
||||
*/
|
||||
export class CacheObject extends DurableObject {
|
||||
private lru = new LRUMap<string, { match: CacheObjectProp | undefined }>(500);
|
||||
|
||||
/**
|
||||
* Open a descriptor to access the cache object.
|
||||
* The goal is to minimize the amount of RPC sessions between the client and the cache object.
|
||||
* One session is opened per request on the client side and used to perform multiple operations.
|
||||
* https://developers.cloudflare.com/workers/runtime-apis/rpc/#return-functions-from-rpc-methods
|
||||
*/
|
||||
public open(): CacheObjectDescriptor {
|
||||
return {
|
||||
get: async <Value = unknown>(key: string) => {
|
||||
return this.get<Value>(key);
|
||||
},
|
||||
set: async <Value = unknown>(key: string, value: Value, expiresAt: number) => {
|
||||
await this.set(key, value, expiresAt);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a property.
|
||||
*/
|
||||
public async get<Value = unknown>(key: string) {
|
||||
return timeFn(`get: ${key}`, async () => {
|
||||
// Try the memory state first.
|
||||
const memoryEntry = this.lru.get(key);
|
||||
if (memoryEntry) {
|
||||
console.log(`get: (memory, ${!!memoryEntry.match}) ${key}`);
|
||||
if (!memoryEntry.match) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (memoryEntry.match.expiresAt > Date.now()) {
|
||||
return memoryEntry.match.value as Value;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.getFromStorage<Value>(key);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a property from the DO storage.
|
||||
*/
|
||||
public async getFromStorage<Value = unknown>(key: string) {
|
||||
const entries = await this.ctx.storage.list<Uint8Array>({
|
||||
prefix: getStoragePropKey(key),
|
||||
noCache: true,
|
||||
});
|
||||
if (entries.size) {
|
||||
const entry = decodeChunks<CacheObjectProp<Value>>(entries);
|
||||
if (entry && entry.expiresAt > Date.now()) {
|
||||
// Found
|
||||
this.lru.set(key, { match: entry });
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Not found
|
||||
this.lru.set(key, { match: undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in the cache object.
|
||||
*/
|
||||
public async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
|
||||
return timeFn(`set: ${key}`, async () => {
|
||||
const prop: CacheObjectProp<Value> = {
|
||||
value,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
this.lru.set(key, { match: prop });
|
||||
await this.ctx.storage.transaction(async (tx) => {
|
||||
const entries = encodeChunks(key, prop);
|
||||
|
||||
const clockValue: CacheObjectExp = {
|
||||
k: key,
|
||||
c: Object.keys(entries).length,
|
||||
};
|
||||
|
||||
await tx.put(getGCClockKey(key, expiresAt), clockValue);
|
||||
await tx.put(entries);
|
||||
|
||||
const currentAlarm = await tx.getAlarm();
|
||||
if (!currentAlarm) {
|
||||
// Set an alarm to garbage collect all entries that have expired in 12h.
|
||||
await tx.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge all keys in the cache object.
|
||||
*/
|
||||
public async purge() {
|
||||
let result = new Set<string>();
|
||||
|
||||
try {
|
||||
// List all the keys in the cache object.
|
||||
const entries = await this.ctx.storage.list<CacheObjectExp>({
|
||||
prefix: 'exp.',
|
||||
noCache: true,
|
||||
});
|
||||
console.log(`purge: ${entries.size} entries`);
|
||||
entries.forEach((exp) => {
|
||||
result.add(exp.k);
|
||||
});
|
||||
} catch (error) {
|
||||
// If an error occurs, reset the cache object.
|
||||
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
|
||||
console.error('Error during purge, resetting the cache object', error);
|
||||
}
|
||||
|
||||
await this.reset();
|
||||
|
||||
console.log(`purge returns`, Array.from(result));
|
||||
return Array.from(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alarm to garbage collect all entries that have expired.
|
||||
*/
|
||||
async alarm() {
|
||||
try {
|
||||
const entries = await this.ctx.storage.list<CacheObjectExp>({
|
||||
prefix: 'exp.',
|
||||
noCache: true,
|
||||
});
|
||||
const toDeleteSet = new Set<string>();
|
||||
|
||||
for (const [key, exp] of entries) {
|
||||
const timestamp = parseInt(key.split('.')[1]);
|
||||
if (timestamp < Date.now()) {
|
||||
toDeleteSet.add(key);
|
||||
for (let i = 0; i < exp.c; i++) {
|
||||
toDeleteSet.add(getStoragePropChunkKey(exp.k, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the keys by batch of 128.
|
||||
const toDelete = Array.from(toDeleteSet);
|
||||
for (let i = 0; i < toDelete.length; i += 128) {
|
||||
await this.ctx.storage.delete(toDelete.slice(i, i + 128));
|
||||
}
|
||||
|
||||
// If there are still keys to delete, set an alarm to continue the deletion in 12h.
|
||||
if (toDelete.length) {
|
||||
await this.ctx.storage.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
// If an error occurs, reset the cache object.
|
||||
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
|
||||
console.error('Error during alarm, reset the cache object', error);
|
||||
await this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the cache object.
|
||||
*/
|
||||
async reset() {
|
||||
console.log('reset: clear all entries');
|
||||
this.lru.clear();
|
||||
await this.ctx.storage.deleteAll();
|
||||
}
|
||||
}
|
||||
|
||||
function getStoragePropKey(key: string): string {
|
||||
return `prop.${key}.`;
|
||||
}
|
||||
|
||||
function getStoragePropChunkKey(key: string, index: number): string {
|
||||
return `${getStoragePropKey(key)}${index}`;
|
||||
}
|
||||
|
||||
function getGCClockRootKey(timestamp: number): string {
|
||||
return `exp.${timestamp}.`;
|
||||
}
|
||||
|
||||
function getGCClockKey(key: string, expiresAt: number): string {
|
||||
return `${getGCClockRootKey(expiresAt)}${key}`;
|
||||
}
|
||||
|
||||
function encodeChunks<T>(key: string, value: T): Record<string, Uint8Array> {
|
||||
const buf = encode(value);
|
||||
const entries: Record<string, Uint8Array> = {};
|
||||
const chunks = chunkUint8Array(buf, 128 * 1024);
|
||||
|
||||
for (let index = 0; index < chunks.length; index++) {
|
||||
entries[getStoragePropChunkKey(key, index)] = chunks[index];
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function decodeChunks<T>(entries: Map<string, Uint8Array>): T | undefined {
|
||||
const chunks = Array.from(entries.entries())
|
||||
.map(([key, value]) => {
|
||||
const index = parseInt(key.split('.').pop()!);
|
||||
return [index, value] as const;
|
||||
})
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, value]) => value);
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buf = mergeUint8Array(chunks);
|
||||
return decode(buf) as T;
|
||||
}
|
||||
|
||||
function chunkUint8Array(input: Uint8Array, chunkSize: number): Uint8Array[] {
|
||||
const chunks: Uint8Array[] = [];
|
||||
for (let i = 0; i < input.length; i += chunkSize) {
|
||||
chunks.push(input.slice(i, i + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function mergeUint8Array(chunks: Uint8Array[]): Uint8Array {
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function timeFn<T>(message: string, fn: () => Promise<T>): Promise<T> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
console.log(`${message} (${performance.now() - start}ms)`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { CacheObject, CacheObjectDescriptor } from './CacheObject';
|
||||
|
||||
export type CacheLocationId = ContinentCode;
|
||||
const allLocations: CacheLocationId[] = ['AF', 'AS', 'NA', 'SA', 'AN', 'EU', 'OC'];
|
||||
|
||||
/**
|
||||
* Location hint for the CacheObject durable object.
|
||||
*/
|
||||
const doLocationHints: {
|
||||
[key in CacheLocationId]: DurableObjectLocationHint;
|
||||
} = {
|
||||
AF: 'afr',
|
||||
AS: 'apac',
|
||||
NA: 'wnam',
|
||||
SA: 'sam',
|
||||
AN: 'oc',
|
||||
EU: 'weur',
|
||||
OC: 'oc',
|
||||
};
|
||||
|
||||
/**
|
||||
* Client to access a cache tag.
|
||||
*/
|
||||
export class CacheObjectStub {
|
||||
private opened: CacheObjectDescriptor | null = null;
|
||||
|
||||
constructor(
|
||||
/** Binding to the CacheObject durable object */
|
||||
private doNamespace: DurableObjectNamespace<CacheObject>,
|
||||
/** ID of the location to target */
|
||||
private locationId: CacheLocationId,
|
||||
/** Name of the tag */
|
||||
private tag: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Open the cache object.
|
||||
*/
|
||||
async open() {
|
||||
if (!this.opened) {
|
||||
const groupId = getCacheObjectIdName(this.locationId, this.tag);
|
||||
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
|
||||
// Initialize the object with a locaiton hint,
|
||||
// as we might want to purge all locations before the object is created.
|
||||
// https://developers.cloudflare.com/durable-objects/reference/data-location/
|
||||
locationHint: doLocationHints[this.locationId],
|
||||
});
|
||||
this.opened = await cacheGroup.open();
|
||||
}
|
||||
|
||||
return this.opened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from the cache.
|
||||
*/
|
||||
async get<Value = unknown>(key: string) {
|
||||
const desc = await this.open();
|
||||
return await desc.get<Value>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in the cache.
|
||||
*/
|
||||
async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
|
||||
// TODO: Should we write on all locations instead of just the current one?
|
||||
const desc = await this.open();
|
||||
return await desc.set<Value>(key, value, expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge all keys in the cache tag.
|
||||
*/
|
||||
async purge() {
|
||||
const keys = new Set<string>();
|
||||
await Promise.all(
|
||||
allLocations.map(async (locationId) => {
|
||||
const groupId = getCacheObjectIdName(locationId, this.tag);
|
||||
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId));
|
||||
const locationkeys = await cacheGroup.purge();
|
||||
locationkeys.forEach((key) => keys.add(key));
|
||||
}),
|
||||
);
|
||||
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
function getCacheObjectIdName(locationId: CacheLocationId, tag: string): string {
|
||||
return `${locationId}:${tag}`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './CacheObjectStub';
|
||||
@@ -0,0 +1,9 @@
|
||||
import { WorkerEntrypoint } from 'cloudflare:workers';
|
||||
|
||||
export * from './CacheObject';
|
||||
|
||||
export default class Worker extends WorkerEntrypoint {
|
||||
fetch() {
|
||||
return new Response('Hello, world!');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"incremental": true,
|
||||
"types": ["./.wrangler/types/runtime.d.ts"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
main = "./src/index.ts"
|
||||
name = "gitbook-open-cache"
|
||||
compatibility_date = "2024-09-02"
|
||||
|
||||
durable_objects.bindings = [
|
||||
{name = "CACHE", class_name = "CacheObject"}
|
||||
]
|
||||
|
||||
migrations = [
|
||||
{tag = "v1", new_classes = ["CacheObject"]}
|
||||
]
|
||||
|
||||
[env.preview]
|
||||
name = "gitbook-open-cache-preview"
|
||||
durable_objects.bindings = [
|
||||
{name = "CACHE", class_name = "CacheObject"}
|
||||
]
|
||||
@@ -9,6 +9,7 @@
|
||||
"emoji-assets": "^8.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "bun ./build.ts"
|
||||
"generate": "bun ./build.ts",
|
||||
"clean": "rm -rf ./dist"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import type { CacheObject } from '@gitbook/cache-do';
|
||||
|
||||
declare global {
|
||||
interface CloudflareEnv {
|
||||
CACHE?: DurableObjectNamespace<CacheObject>;
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,10 @@ import { getContentTestURL } from '../tests/utils';
|
||||
|
||||
interface Test {
|
||||
name: string;
|
||||
url: string;
|
||||
run?: (page: Page) => Promise<unknown>;
|
||||
fullPage?: boolean;
|
||||
screenshot?: false;
|
||||
url: string; // URL to visit for testing
|
||||
run?: (page: Page) => Promise<unknown>; // The test to run
|
||||
fullPage?: boolean; // Whether the test should be fullscreened during testing
|
||||
screenshot?: false; // Should a screenshot be stored
|
||||
}
|
||||
|
||||
interface TestsCase {
|
||||
@@ -106,6 +106,31 @@ const testCases: TestsCase[] = [
|
||||
name: 'RFC variant',
|
||||
url: 'v/rfcs',
|
||||
},
|
||||
{
|
||||
name: 'Customized variant titles are displayed',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = page.locator('[data-testid="space-dropdown-button"]');
|
||||
await spaceDrowpdown.click();
|
||||
|
||||
const variantSelectionDropdown = page.locator(
|
||||
'css=[data-testid="space-dropdown-button"] + div',
|
||||
);
|
||||
// the customized space title
|
||||
await expect(
|
||||
variantSelectionDropdown.getByRole('link', {
|
||||
name: 'Multi-Variants',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// the NON-customized space title
|
||||
await expect(
|
||||
variantSelectionDropdown.getByRole('link', {
|
||||
name: 'RFCs',
|
||||
}),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -658,6 +683,57 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'SEO',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: `Index by default`,
|
||||
url: '?x-gitbook-search-indexation=true',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const metaRobots = page.locator('meta[name="robots"]');
|
||||
await expect(metaRobots).toHaveAttribute('content', 'index, follow');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: `Don't index noIndex`,
|
||||
url: 'page-options/page-no-index?x-gitbook-search-indexation=true',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const metaRobots = page.locator('meta[name="robots"]');
|
||||
await expect(metaRobots).toHaveAttribute('content', 'noindex, nofollow');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: `Don't index descendant of noIndex`,
|
||||
url: 'page-options/page-no-index/descendant-of-page-no-index?x-gitbook-search-indexation=true',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const metaRobots = page.locator('meta[name="robots"]');
|
||||
await expect(metaRobots).toHaveAttribute('content', 'noindex, nofollow');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: `Don't index noRobotsIndex`,
|
||||
url: 'page-options/page-no-robots-index?x-gitbook-search-indexation=true',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const metaRobots = page.locator('meta[name="robots"]');
|
||||
await expect(metaRobots).toHaveAttribute('content', 'noindex, nofollow');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: `Don't index descendant of noRobotsIndex`,
|
||||
url: 'page-options/page-no-robots-index/descendant-of-page-no-robots-index?x-gitbook-search-indexation=true',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const metaRobots = page.locator('meta[name="robots"]');
|
||||
await expect(metaRobots).toHaveAttribute('content', 'noindex, nofollow');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
|
||||
@@ -12,15 +12,17 @@
|
||||
"e2e": "playwright test",
|
||||
"unit": "bun test {src,packages}",
|
||||
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math",
|
||||
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons"
|
||||
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons",
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.60.0",
|
||||
"@gitbook/api": "0.60.0",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@sentry/nextjs": "^7.94.1",
|
||||
|
||||
@@ -29,7 +29,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
keys: result.keys,
|
||||
stats: result.stats,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function PageSkeleton() {
|
||||
'lg:items-start',
|
||||
)}
|
||||
>
|
||||
<div className={tcls('flex-1', 'max-w-3xl', 'mx-auto')}>
|
||||
<div className={tcls('flex-1', 'max-w-3xl', 'mx-auto', 'page-full-width:mx-0')}>
|
||||
<SkeletonHeading style={tcls('mb-8')} />
|
||||
<SkeletonParagraph style={tcls('mb-4')} />
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PageBody, PageCover } from '@/components/PageBody';
|
||||
import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
|
||||
import { getPagePath, resolveFirstDocument } from '@/lib/pages';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
import { isSpaceIndexable, isPageIndexable } from '@/lib/seo';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
@@ -129,7 +130,7 @@ export async function generateMetadata({
|
||||
params: PagePathParams;
|
||||
searchParams: { fallback?: string };
|
||||
}): Promise<Metadata> {
|
||||
const { space, pages, page, customization, parent } = await getPageDataWithFallback({
|
||||
const { space, pages, page, customization, parent, ancestors } = await getPageDataWithFallback({
|
||||
pagePathParams: params,
|
||||
searchParams,
|
||||
});
|
||||
@@ -152,6 +153,10 @@ export async function generateMetadata({
|
||||
absoluteHref(`~gitbook/ogimage/${page.id}`, true),
|
||||
],
|
||||
},
|
||||
robots:
|
||||
isSpaceIndexable({ space, parent }) && isPageIndexable(ancestors, page)
|
||||
? 'index, follow'
|
||||
: 'noindex, nofollow',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { assetsDomain } from '@/lib/assets';
|
||||
import { buildVersion } from '@/lib/build';
|
||||
import { getContentSecurityPolicyNonce } from '@/lib/csp';
|
||||
import { absoluteHref, baseUrl } from '@/lib/links';
|
||||
import { shouldIndexSpace } from '@/lib/seo';
|
||||
import { isSpaceIndexable } from '@/lib/seo';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { ClientContexts } from './ClientContexts';
|
||||
@@ -133,7 +133,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
},
|
||||
],
|
||||
},
|
||||
robots: shouldIndexSpace({ space, parent }) ? 'index, follow' : 'noindex, nofollow',
|
||||
robots: isSpaceIndexable({ space, parent }) ? 'index, follow' : 'noindex, nofollow',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextRequest } from 'next/server';
|
||||
|
||||
import { getCollection, getSite, getSpace } from '@/lib/api';
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { shouldIndexSpace } from '@/lib/seo';
|
||||
import { isSpaceIndexable } from '@/lib/seo';
|
||||
|
||||
import { getContentPointer } from '../../fetch';
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function GET(req: NextRequest) {
|
||||
const lines = [
|
||||
`User-agent: *`,
|
||||
'Disallow: /~gitbook/',
|
||||
...(shouldIndexSpace({ space, parent })
|
||||
...(isSpaceIndexable({ space, parent })
|
||||
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
|
||||
: [`Disallow: /`]),
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NextRequest } from 'next/server';
|
||||
import { getSpaceContentData } from '@/lib/api';
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { isPageIndexable } from '@/lib/seo';
|
||||
|
||||
import { getContentPointer } from '../../fetch';
|
||||
|
||||
@@ -19,7 +20,8 @@ export async function GET(req: NextRequest) {
|
||||
pointer,
|
||||
'siteId' in pointer ? pointer.siteShareKey : undefined,
|
||||
);
|
||||
const pages = flattenPages(rootPages, (page) => !page.hidden);
|
||||
|
||||
const pages = flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page));
|
||||
const urls = pages.map(({ page, depth }) => {
|
||||
// Decay priority with depth
|
||||
const priority = Math.pow(2, -0.25 * depth);
|
||||
@@ -72,14 +74,19 @@ type FlatPageEntry = { page: RevisionPageDocument; depth: number };
|
||||
|
||||
function flattenPages(
|
||||
rootPags: RevisionPage[],
|
||||
filter: (page: RevisionPageDocument) => boolean,
|
||||
filter: (page: RevisionPageDocument | RevisionPageGroup) => boolean,
|
||||
): FlatPageEntry[] {
|
||||
const flattenPage = (
|
||||
page: RevisionPageDocument | RevisionPageGroup,
|
||||
depth: number,
|
||||
): FlatPageEntry[] => {
|
||||
const allowed = filter(page);
|
||||
if (!allowed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...(page.type === 'document' && filter(page) ? [{ page, depth }] : []),
|
||||
...(page.type === 'document' ? [{ page, depth }] : []),
|
||||
...page.pages.flatMap((child) =>
|
||||
child.type === 'link' ? [] : flattenPage(child, depth + 1),
|
||||
),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getCurrentSiteData,
|
||||
getSite,
|
||||
getSiteSpaces,
|
||||
getCurrentSiteCustomization,
|
||||
} from '@/lib/api';
|
||||
import { resolvePagePath, resolvePageId } from '@/lib/pages';
|
||||
|
||||
@@ -87,11 +88,13 @@ export async function fetchSpaceData() {
|
||||
);
|
||||
|
||||
const parent = await (parentSite ?? fetchParentCollection(space));
|
||||
// we grab the space attached to the parent as it contains overriden customizations
|
||||
const spaceRelativeToParent = parent?.spaces.find((space) => space.id === content.spaceId);
|
||||
|
||||
return {
|
||||
content,
|
||||
contentTarget,
|
||||
space,
|
||||
space: spaceRelativeToParent ?? space,
|
||||
pages,
|
||||
customization,
|
||||
scripts,
|
||||
@@ -195,23 +198,34 @@ async function fetchParentSite(args: {
|
||||
siteShareKey: string | undefined;
|
||||
}) {
|
||||
const { organizationId, siteId, siteShareKey } = args;
|
||||
const [site, siteSpaces] = await Promise.all([
|
||||
const [site, siteSpaces, siteParentCustomizations] = await Promise.all([
|
||||
getSite(organizationId, siteId),
|
||||
getSiteSpaces({ organizationId, siteId, siteShareKey }),
|
||||
getCurrentSiteCustomization({ organizationId, siteId, siteSpaceId: undefined }),
|
||||
]);
|
||||
|
||||
const spaces: Record<string, Space> = {};
|
||||
const spaces: Array<Space> = [];
|
||||
siteSpaces.forEach((siteSpace) => {
|
||||
spaces[siteSpace.space.id] = {
|
||||
spaces.push({
|
||||
...siteSpace.space,
|
||||
title: siteSpace.title ?? siteSpace.space.title,
|
||||
urls: {
|
||||
...siteSpace.space.urls,
|
||||
published: siteSpace.urls.published,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return { parent: site, spaces: Object.values(spaces) };
|
||||
// override the title with the customization title
|
||||
const parent = {
|
||||
...site,
|
||||
...(siteParentCustomizations?.title ? { title: siteParentCustomizations.title } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
parent,
|
||||
spaces,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { renderAd } from './renderAd';
|
||||
@@ -78,7 +79,6 @@ export function Ad({
|
||||
|
||||
(async () => {
|
||||
const result = await renderAd({
|
||||
spaceId,
|
||||
placement,
|
||||
ignore: ignore || preview,
|
||||
zoneId: realZoneId,
|
||||
@@ -97,7 +97,7 @@ export function Ad({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visible, spaceId, zoneId, ignore, placement, mode]);
|
||||
}, [visible, zoneId, ignore, placement, mode]);
|
||||
|
||||
const viaUrl = new URL('https://www.gitbook.com');
|
||||
viaUrl.searchParams.set('utm_source', 'content');
|
||||
@@ -106,7 +106,39 @@ export function Ad({
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={tcls(style)}>
|
||||
{ad ? ad : null}
|
||||
{ad ? (
|
||||
<>
|
||||
{ad}
|
||||
<AdSponsoredLink spaceId={spaceId} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdSponsoredLink(props: { spaceId: string }) {
|
||||
const { spaceId } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
const viaUrl = new URL('https://www.gitbook.com');
|
||||
viaUrl.searchParams.set('utm_source', 'content');
|
||||
viaUrl.searchParams.set('utm_medium', 'sponsoring');
|
||||
viaUrl.searchParams.set('utm_campaign', spaceId);
|
||||
|
||||
return (
|
||||
<p
|
||||
className={tcls(
|
||||
'mt-2',
|
||||
'mr-2',
|
||||
'text-xs',
|
||||
'text-right',
|
||||
'text-dark/5',
|
||||
'dark:text-light/5',
|
||||
)}
|
||||
>
|
||||
<a target="_blank" href={viaUrl.toString()} className={tcls('hover:underline')}>
|
||||
{t(language, 'sponsored_via_gitbook')}
|
||||
</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AdClassicRendering } from './AdClassicRendering';
|
||||
import { AdCoverRendering } from './AdCoverRendering';
|
||||
import { AdPixels } from './AdPixels';
|
||||
@@ -12,8 +10,6 @@ import { AdItem, AdsResponse } from './types';
|
||||
interface FetchAdOptions {
|
||||
/** ID of the zone to fetch Ads for */
|
||||
zoneId: string;
|
||||
/** ID of the space */
|
||||
spaceId: string;
|
||||
/** Mode to render the Ad */
|
||||
mode: 'classic' | 'auto' | 'cover';
|
||||
/** Name of the placement for the ad */
|
||||
@@ -28,18 +24,13 @@ interface FetchAdOptions {
|
||||
* and properly access user-agent and IP.
|
||||
*/
|
||||
export async function renderAd(options: FetchAdOptions) {
|
||||
const { spaceId, mode } = options;
|
||||
const { mode } = options;
|
||||
const result = await fetchAd(options);
|
||||
if (!result || !result.ad.description || !result.ad.statlink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { ad, ip } = result;
|
||||
|
||||
const viaUrl = new URL('https://www.gitbook.com');
|
||||
viaUrl.searchParams.set('utm_source', 'content');
|
||||
viaUrl.searchParams.set('utm_medium', 'ads');
|
||||
viaUrl.searchParams.set('utm_campaign', spaceId);
|
||||
const { ad } = result;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -49,21 +40,6 @@ export async function renderAd(options: FetchAdOptions) {
|
||||
<AdCoverRendering ad={ad} />
|
||||
)}
|
||||
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
|
||||
<p
|
||||
className={tcls(
|
||||
'mt-2',
|
||||
'mr-2',
|
||||
'text-xs',
|
||||
'text-right',
|
||||
'text-dark/5',
|
||||
'dark:text-light/5',
|
||||
)}
|
||||
data-debug-ip={ip}
|
||||
>
|
||||
<a target="_blank" href={viaUrl.toString()} className={tcls('hover:underline')}>
|
||||
Sponsored via GitBook
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function Blocks<T extends DocumentBlock, Tag extends React.ElementType =
|
||||
/** Ancestors of the blocks */
|
||||
ancestorBlocks: DocumentBlock[];
|
||||
|
||||
/** HTML tag to use */
|
||||
/** HTML tag to use for the wrapper */
|
||||
tag?: Tag;
|
||||
|
||||
/** Style passed to the wrapper */
|
||||
@@ -24,12 +24,15 @@ export function Blocks<T extends DocumentBlock, Tag extends React.ElementType =
|
||||
|
||||
/** Style passed to all blocks */
|
||||
blockStyle?: ClassValue;
|
||||
|
||||
/** Props to pass to the wrapper element */
|
||||
wrapperProps?: React.ComponentProps<Tag>;
|
||||
},
|
||||
) {
|
||||
const { nodes, tag: Tag = 'div', style, blockStyle, ...contextProps } = props;
|
||||
const { nodes, tag: Tag = 'div', style, blockStyle, wrapperProps, ...contextProps } = props;
|
||||
|
||||
return (
|
||||
<Tag className={tcls(style)}>
|
||||
<Tag {...wrapperProps} className={tcls(style)}>
|
||||
{nodes.map((node, index) => (
|
||||
<Block
|
||||
key={node.key}
|
||||
|
||||
@@ -56,6 +56,7 @@ async function OpenAPIBody(props: BlockProps<DocumentBlockSwagger>) {
|
||||
CodeBlock: PlainCodeBlock,
|
||||
defaultInteractiveOpened: context.mode === 'print',
|
||||
id: block.meta?.id,
|
||||
blockKey: block.key,
|
||||
}}
|
||||
className="openapi-block"
|
||||
/>
|
||||
|
||||
@@ -174,7 +174,8 @@
|
||||
@apply prose-sm;
|
||||
}
|
||||
|
||||
.openapi-schema-example {
|
||||
.openapi-schema-example,
|
||||
.openapi-schema-pattern {
|
||||
@apply prose-sm mt-2 text-dark/10 dark:text-light/10;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ContentRef, DocumentTableViewCards } from '@gitbook/api';
|
||||
import React from 'react';
|
||||
|
||||
import { Image } from '@/components/utils';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
@@ -12,7 +13,7 @@ export async function RecordCard(
|
||||
record: TableRecordKV;
|
||||
},
|
||||
) {
|
||||
const { view, record, context, isOffscreen } = props;
|
||||
const { view, record, context, block, isOffscreen } = props;
|
||||
|
||||
const coverFile = view.coverDefinition
|
||||
? getRecordValue<string[]>(record[1], view.coverDefinition)?.[0]
|
||||
@@ -99,6 +100,31 @@ export async function RecordCard(
|
||||
)}
|
||||
>
|
||||
{view.columns.map((column) => {
|
||||
const definition = block.data.definition[column];
|
||||
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!view.hideColumnTitle && definition.title) {
|
||||
const ariaLabelledBy = `${block.key}-${column}-title`;
|
||||
return (
|
||||
<div key={column} className="flex flex-col gap-1">
|
||||
<div
|
||||
id={ariaLabelledBy}
|
||||
className="text-sm text-dark/8 dark:text-light/8"
|
||||
>
|
||||
{definition.title}
|
||||
</div>
|
||||
<RecordColumnValue
|
||||
{...props}
|
||||
column={column}
|
||||
ariaLabelledBy={ariaLabelledBy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <RecordColumnValue key={column} {...props} column={column} />;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -22,11 +22,12 @@ import { FileIcon } from '../FileIcon';
|
||||
export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
props: BlockProps<DocumentBlockTable> & {
|
||||
tag?: Tag;
|
||||
ariaLabelledBy?: string;
|
||||
record: TableRecordKV;
|
||||
column: string;
|
||||
},
|
||||
) {
|
||||
const { tag: Tag = 'div', block, document, record, column, context } = props;
|
||||
const { tag: Tag = 'div', ariaLabelledBy, block, document, record, column, context } = props;
|
||||
|
||||
const definition = block.data.definition[column];
|
||||
const value = record[1].values[column];
|
||||
@@ -42,6 +43,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
className={tcls('w-5', 'h-5')}
|
||||
checked={value as boolean}
|
||||
disabled={true}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
/>
|
||||
);
|
||||
case 'rating':
|
||||
@@ -67,7 +69,8 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
</span>
|
||||
<span
|
||||
role="meter"
|
||||
aria-label={definition.title ?? ''}
|
||||
aria-label={ariaLabelledBy ? undefined : (definition.title ?? '')}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-valuenow={rating}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={definition.max}
|
||||
@@ -89,6 +92,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
return (
|
||||
<Tag
|
||||
className={tcls('text-base', 'tabular-nums', 'tracking-tighter')}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
>{`${value}`}</Tag>
|
||||
);
|
||||
case 'text':
|
||||
@@ -116,6 +120,9 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
]}
|
||||
context={context}
|
||||
blockStyle={['w-full', 'max-w-[unset]']}
|
||||
wrapperProps={{
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'files':
|
||||
@@ -129,7 +136,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
);
|
||||
|
||||
return (
|
||||
<Tag className={tcls('text-base')}>
|
||||
<Tag className={tcls('text-base')} aria-labelledby={ariaLabelledBy}>
|
||||
{files.filter(filterOutNullable).map((ref, index) => {
|
||||
const contentType = ref.file
|
||||
? getSimplifiedContentType(ref.file.contentType)
|
||||
@@ -178,7 +185,10 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<Tag className={tcls('text-base', 'text-balance', 'flex', 'items-center')}>
|
||||
<Tag
|
||||
className={tcls('text-base', 'text-balance', 'flex', 'items-center')}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
>
|
||||
{resolved?.icon ?? null}
|
||||
{resolved ? (
|
||||
<StyledLink href={resolved.href}>{resolved.text}</StyledLink>
|
||||
@@ -197,7 +207,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
);
|
||||
|
||||
return (
|
||||
<Tag className={tcls('text-base')}>
|
||||
<Tag className={tcls('text-base')} aria-labelledby={ariaLabelledBy}>
|
||||
{resolved.filter(filterOutNullable).map((file, index) => (
|
||||
<StyledLink key={index} href={file.href}>
|
||||
{file.text}
|
||||
@@ -208,7 +218,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
}
|
||||
case 'select': {
|
||||
return (
|
||||
<Tag className={tcls()}>
|
||||
<Tag aria-labelledby={ariaLabelledBy}>
|
||||
<span className={tcls('inline-flex', 'gap-2', 'flex-wrap')}>
|
||||
{(value as string[]).map((selectId) => {
|
||||
const option = definition.options.find(
|
||||
|
||||
@@ -1,23 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { atom, selectorFamily, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { useHash, useIsMounted } from '@/components/hooks';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
// How many titles are remembered:
|
||||
const TITLES_MAX = 5;
|
||||
|
||||
export interface TabsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// https://github.com/facebookexperimental/Recoil/issues/629#issuecomment-914273925
|
||||
type SelectorMapper<Type> = {
|
||||
[Property in keyof Type]: Type[Property];
|
||||
};
|
||||
type TabsInput = {
|
||||
id: string;
|
||||
tabs: SelectorMapper<TabsItem>[];
|
||||
};
|
||||
|
||||
interface TabsState {
|
||||
activeIds: {
|
||||
[tabsBlockId: string]: string;
|
||||
};
|
||||
activeTitles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client side component for the tabs, taking care of interactions.
|
||||
*/
|
||||
export function DynamicTabs(props: {
|
||||
tabs: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}>;
|
||||
style: ClassValue;
|
||||
}) {
|
||||
const { tabs, style } = props;
|
||||
export function DynamicTabs(
|
||||
props: TabsInput & {
|
||||
tabsBody: React.ReactNode[];
|
||||
style: ClassValue;
|
||||
},
|
||||
) {
|
||||
const { id, tabs, tabsBody, style } = props;
|
||||
|
||||
const [active, setActive] = React.useState<null | string>(tabs[0].id);
|
||||
const hash = useHash();
|
||||
|
||||
const activeState = useRecoilValue(tabsActiveSelector({ id, tabs }));
|
||||
|
||||
// To avoid issue with hydration, we only use the state from recoil (which is loaded from localstorage),
|
||||
// once the component has been mounted.
|
||||
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
|
||||
// and get stuck into an inconsistent state.
|
||||
const mounted = useIsMounted();
|
||||
const active = mounted ? activeState : tabs[0];
|
||||
|
||||
const setTabsState = useSetRecoilState(tabsAtom);
|
||||
|
||||
/**
|
||||
* When clicking to select a tab, we:
|
||||
* - mark this specific ID as selected
|
||||
* - store the ID to auto-select other tabs with the same title
|
||||
*/
|
||||
const onSelectTab = React.useCallback(
|
||||
(tab: TabsItem) => {
|
||||
setTabsState((prev) => ({
|
||||
activeIds: {
|
||||
...prev.activeIds,
|
||||
[id]: tab.id,
|
||||
},
|
||||
activeTitles: tab.title
|
||||
? prev.activeTitles
|
||||
.filter((t) => t !== tab.title)
|
||||
.concat([tab.title])
|
||||
.slice(-TITLES_MAX)
|
||||
: prev.activeTitles,
|
||||
}));
|
||||
},
|
||||
[id, setTabsState],
|
||||
);
|
||||
|
||||
/**
|
||||
* When the hash changes, we try to select the tab containing the targetted element.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!hash) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = document.getElementById(hash);
|
||||
if (!activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabAncestor = activeElement.closest('[role="tabpanel"]');
|
||||
if (!tabAncestor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = tabs.find((tab) => getTabPanelId(tab.id) === tabAncestor.id);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectTab(tab);
|
||||
}, [hash, tabs, onSelectTab]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -52,11 +136,11 @@ export function DynamicTabs(props: {
|
||||
<button
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={active === tab.id}
|
||||
aria-controls={`tabpanel-${tab.id}`}
|
||||
id={`tab-${tab.id}`}
|
||||
aria-selected={active.id === tab.id}
|
||||
aria-controls={getTabPanelId(tab.id)}
|
||||
id={getTabButtonId(tab.id)}
|
||||
onClick={() => {
|
||||
setActive(tab.id);
|
||||
onSelectTab(tab);
|
||||
}}
|
||||
className={tcls(
|
||||
//prev from active-tab
|
||||
@@ -102,7 +186,7 @@ export function DynamicTabs(props: {
|
||||
'truncate',
|
||||
'max-w-full',
|
||||
|
||||
active === tab.id
|
||||
active.id === tab.id
|
||||
? [
|
||||
'shrink-0',
|
||||
'active-tab',
|
||||
@@ -121,17 +205,97 @@ export function DynamicTabs(props: {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tabs.map((tab) => (
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
role="tabpanel"
|
||||
id={`tabpanel-${tab.id}`}
|
||||
aria-labelledby={`tab-${tab.id}`}
|
||||
className={tcls('p-4', tab.id !== active ? 'hidden' : null)}
|
||||
id={getTabPanelId(tab.id)}
|
||||
aria-labelledby={getTabButtonId(tab.id)}
|
||||
className={tcls('p-4', tab.id !== active.id ? 'hidden' : null)}
|
||||
>
|
||||
{tab.children}
|
||||
{tabsBody[index]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabsAtom = atom<TabsState>({
|
||||
key: 'tabsAtom',
|
||||
default: {
|
||||
activeIds: {},
|
||||
activeTitles: [],
|
||||
},
|
||||
effects: [
|
||||
// Persist the state to local storage
|
||||
({ trigger, setSelf, onSet }) => {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const localStorageKey = '@gitbook/tabsState';
|
||||
if (trigger === 'get') {
|
||||
const stored = localStorage.getItem(localStorageKey);
|
||||
if (stored) {
|
||||
setSelf(JSON.parse(stored));
|
||||
}
|
||||
}
|
||||
|
||||
onSet((newState) => {
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(newState));
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const tabsActiveSelector = selectorFamily<TabsItem, SelectorMapper<TabsInput>>({
|
||||
key: 'tabsActiveSelector',
|
||||
get:
|
||||
(input) =>
|
||||
({ get }) => {
|
||||
const state = get(tabsAtom);
|
||||
return getTabBySelection(input, state) ?? getTabByTitle(input, state) ?? input.tabs[0];
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the ID for a tab button.
|
||||
*/
|
||||
function getTabButtonId(tabId: string) {
|
||||
return `tab-${tabId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID for a tab panel.
|
||||
* We use the ID of the tab itself as links can be pointing to this ID.
|
||||
*/
|
||||
function getTabPanelId(tabId: string) {
|
||||
return tabId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get explicitly selected tab in a set of tabs.
|
||||
*/
|
||||
function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null {
|
||||
const activeId = state.activeIds[input.id];
|
||||
return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best selected tab in a set of tabs by taking only title into account.
|
||||
*/
|
||||
function getTabByTitle(input: TabsInput, state: TabsState): TabsItem | null {
|
||||
return (
|
||||
input.tabs
|
||||
.map((item) => {
|
||||
return {
|
||||
item,
|
||||
score: state.activeTitles.indexOf(item.title),
|
||||
};
|
||||
})
|
||||
.filter(({ score }) => score >= 0)
|
||||
// .sortBy(({ score }) => -score)
|
||||
.sort(({ score: a }, { score: b }) => b - a)
|
||||
.map(({ item }) => item)[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,23 @@ import { DocumentBlockTabs } from '@gitbook/api';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { DynamicTabs } from './DynamicTabs';
|
||||
import { DynamicTabs, TabsItem } from './DynamicTabs';
|
||||
import { BlockProps } from '../Block';
|
||||
import { Blocks } from '../Blocks';
|
||||
|
||||
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
||||
const { block, ancestorBlocks, document, style, context } = props;
|
||||
|
||||
const tabs = block.nodes.map((tab, index) => ({
|
||||
id: tab.key!,
|
||||
title: tab.data.title ?? '',
|
||||
children: (
|
||||
const tabs: TabsItem[] = [];
|
||||
const tabsBody: React.ReactNode[] = [];
|
||||
|
||||
block.nodes.forEach((tab, index) => {
|
||||
tabs.push({
|
||||
id: tab.meta?.id ?? tab.key!,
|
||||
title: tab.data.title ?? '',
|
||||
});
|
||||
|
||||
tabsBody.push(
|
||||
<Blocks
|
||||
nodes={tab.nodes}
|
||||
document={document}
|
||||
@@ -20,20 +26,26 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
||||
context={context}
|
||||
blockStyle={tcls('flip-heading-hash')}
|
||||
style={tcls('w-full', 'space-y-4')}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
if (context.mode === 'print') {
|
||||
// When printing, we display the tab, one after the other
|
||||
return (
|
||||
<>
|
||||
{tabs.map((tab) => (
|
||||
<DynamicTabs key={tab.id} tabs={[tab]} style={style} />
|
||||
{tabs.map((tab, index) => (
|
||||
<DynamicTabs
|
||||
key={tab.id}
|
||||
id={block.key!}
|
||||
tabs={[tab]}
|
||||
tabsBody={[tabsBody[index]]}
|
||||
style={style}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <DynamicTabs tabs={tabs} style={style} />;
|
||||
return <DynamicTabs id={block.key!} tabs={tabs} tabsBody={tabsBody} style={style} />;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export function Header(props: {
|
||||
label={t(getSpaceLanguage(customization), 'more')}
|
||||
links={customization.header.links}
|
||||
context={context}
|
||||
customization={customization}
|
||||
/>
|
||||
</HeaderLinks>
|
||||
<div
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { CustomizationHeaderLink } from '@gitbook/api';
|
||||
import {
|
||||
CustomizationHeaderLink,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationSettings,
|
||||
SiteCustomizationSettings,
|
||||
} from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Dropdown, DropdownChevron, DropdownMenu, DropdownMenuItem } from './Dropdown';
|
||||
import { Dropdown, DropdownMenu, DropdownMenuItem } from './Dropdown';
|
||||
import styles from './headerLinks.module.css';
|
||||
|
||||
/**
|
||||
@@ -13,13 +20,25 @@ export function HeaderLinkMore(props: {
|
||||
label: React.ReactNode;
|
||||
links: CustomizationHeaderLink[];
|
||||
context: ContentRefContext;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
}) {
|
||||
const { label, links, context } = props;
|
||||
const { label, links, context, customization } = props;
|
||||
|
||||
const isCustomizationDefault =
|
||||
customization.header.preset === CustomizationHeaderPreset.Default;
|
||||
|
||||
const renderButton = () => (
|
||||
<button className="px-1">
|
||||
<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',
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
<DropdownChevron />
|
||||
<Icon icon="ellipsis" className={tcls('opacity-6', 'size-3', 'ms-1')} />
|
||||
</button>
|
||||
);
|
||||
|
||||
|
||||
@@ -85,9 +85,7 @@ export function HeaderLogo(props: HeaderLogoProps) {
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<LogoFallback {...props} />
|
||||
</>
|
||||
<LogoFallback {...props} />
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -98,6 +96,7 @@ function LogoFallback(props: HeaderLogoProps) {
|
||||
const { parent, space, customization } = props;
|
||||
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : undefined;
|
||||
const customEmoji = 'emoji' in customization.favicon ? customization.favicon.emoji : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SpaceIcon
|
||||
|
||||
@@ -63,6 +63,7 @@ export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLB
|
||||
'dark:hover:bg-light/2',
|
||||
'rounded',
|
||||
'straight-corners:rounded-sm',
|
||||
'page-no-toc:hidden',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { PageBodyBlankslate } from './PageBodyBlankslate';
|
||||
import { PageCover } from './PageCover';
|
||||
import { PageFooterNavigation } from './PageFooterNavigation';
|
||||
import { PageHeader } from './PageHeader';
|
||||
import { PreservePageLayout } from './PreservePageLayout';
|
||||
import { TrackPageView } from './TrackPageView';
|
||||
import { DocumentView, createHighlightingContext } from '../DocumentView';
|
||||
import { PageFeedbackForm } from '../PageFeedback';
|
||||
@@ -78,6 +79,7 @@ export function PageBody(props: {
|
||||
(!page.layout.tableOfContents ? ' page-no-toc' : '')
|
||||
}
|
||||
>
|
||||
<PreservePageLayout asFullWidth={asFullWidth} />
|
||||
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
|
||||
<PageCover as="hero" page={page} cover={page.cover} context={context} />
|
||||
) : null}
|
||||
|
||||
@@ -17,7 +17,8 @@ export async function PageBodyBlankslate(props: {
|
||||
}) {
|
||||
const { page, rootPages, context } = props;
|
||||
|
||||
if (!page.pages.length) {
|
||||
const pages = page.pages.filter((child) => (child.type === 'document' ? !child.hidden : true));
|
||||
if (!pages.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ export async function PageBodyBlankslate(props: {
|
||||
)}
|
||||
>
|
||||
{await Promise.all(
|
||||
page.pages.map(async (child) => {
|
||||
pages.map(async (child) => {
|
||||
const icon = (
|
||||
<PageIcon
|
||||
page={child}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* This component preserves the layout of the page while loading a new one.
|
||||
* This approach is needed as page layout (full width block) is done using CSS (`body:has(.page-full-width)`),
|
||||
* which becomes false while transitioning between the 2 page states:
|
||||
*
|
||||
* 1. Page 1 with full width block: `body:has(.page-full-width)` is true
|
||||
* 2. Loading skeleton while transitioning to page 2: `body:has(.page-full-width)` is false
|
||||
* 3. Page 2 with full width block: `body:has(.page-full-width)` is true
|
||||
*
|
||||
* This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2).
|
||||
*/
|
||||
export function PreservePageLayout(props: { asFullWidth: boolean }) {
|
||||
const { asFullWidth } = props;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
// We use the header as it's an element preserved between page transitions
|
||||
// (rendered in the layout component).
|
||||
const header = document.querySelector('header');
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (asFullWidth) {
|
||||
header.classList.add('page-full-width');
|
||||
} else {
|
||||
header.classList.remove('page-full-width');
|
||||
}
|
||||
}, [asFullWidth]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export function TableOfContents(props: {
|
||||
'dark:navigation-open:shadow-light/2',
|
||||
withHeaderOffset ? 'lg:h-[calc(100vh_-_4rem)]' : 'lg:h-[100vh]',
|
||||
withHeaderOffset ? 'lg:top-16' : 'lg:top-0',
|
||||
'page-no-toc:lg:hidden',
|
||||
'page-no-toc:hidden',
|
||||
)}
|
||||
>
|
||||
{header ? header : null}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './useScrollActiveId';
|
||||
export * from './useScrollToHash';
|
||||
export * from './useHash';
|
||||
export * from './useIsMounted';
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
|
||||
export function useHash() {
|
||||
const params = useParams();
|
||||
const [hash, setHash] = React.useState<string>(global.location?.hash?.slice(1));
|
||||
const [hash, setHash] = React.useState<string | null>(global.location?.hash?.slice(1) ?? null);
|
||||
React.useEffect(() => {
|
||||
function updateHash() {
|
||||
setHash(global.location?.hash?.slice(1));
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Hook to check if a component is mounted.
|
||||
*/
|
||||
export function useIsMounted() {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return mounted;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export const de = {
|
||||
locale: 'de',
|
||||
powered_by_gitbook: 'Bereitgestellt von GitBook',
|
||||
sponsored_via_gitbook: 'Gesponsert von GitBook',
|
||||
switch_to_dark_theme: 'Zum dunklen Modus wechseln',
|
||||
switch_to_light_theme: 'Zum hellen Modus wechseln',
|
||||
switch_to_system_theme: 'Zum Systemmodus wechseln',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const en = {
|
||||
locale: 'en',
|
||||
powered_by_gitbook: 'Powered by GitBook',
|
||||
sponsored_via_gitbook: 'Sponsored via GitBook',
|
||||
switch_to_dark_theme: 'Switch to dark theme',
|
||||
switch_to_light_theme: 'Switch to light theme',
|
||||
switch_to_system_theme: 'Switch to system theme',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TranslationLanguage } from './types';
|
||||
export const es: TranslationLanguage = {
|
||||
locale: 'es',
|
||||
powered_by_gitbook: 'Con tecnología de GitBook',
|
||||
sponsored_via_gitbook: 'Patrocinado por GitBook',
|
||||
switch_to_dark_theme: 'Cambiar a tema oscuro',
|
||||
switch_to_light_theme: 'Cambiar a tema claro',
|
||||
switch_to_system_theme: 'Cambiar a tema del sistema',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TranslationLanguage } from './types';
|
||||
export const fr: TranslationLanguage = {
|
||||
locale: 'fr',
|
||||
powered_by_gitbook: 'Propulsé par GitBook',
|
||||
sponsored_via_gitbook: 'Sponsorisé avec GitBook',
|
||||
switch_to_dark_theme: 'Passer au thème sombre',
|
||||
switch_to_light_theme: 'Passer au thème clair',
|
||||
switch_to_system_theme: 'Passer au thème système',
|
||||
|
||||
@@ -5,6 +5,8 @@ import { en } from './en';
|
||||
import { es } from './es';
|
||||
import { fr } from './fr';
|
||||
import { ja } from './ja';
|
||||
import { nl } from './nl';
|
||||
import { no } from './no';
|
||||
import { pt_br } from './pt-br';
|
||||
import { TranslationLanguage } from './types';
|
||||
import { zh } from './zh';
|
||||
@@ -12,6 +14,8 @@ import { zh } from './zh';
|
||||
export * from './types';
|
||||
|
||||
export const languages: {
|
||||
[key: string]: TranslationLanguage;
|
||||
} & {
|
||||
[locale in CustomizationLocale]: TranslationLanguage;
|
||||
} = {
|
||||
de,
|
||||
@@ -20,5 +24,7 @@ export const languages: {
|
||||
es,
|
||||
zh,
|
||||
ja,
|
||||
nl,
|
||||
no,
|
||||
'pt-br': pt_br,
|
||||
};
|
||||
|
||||
@@ -3,11 +3,12 @@ import { TranslationLanguage } from './types';
|
||||
export const ja: TranslationLanguage = {
|
||||
locale: 'ja',
|
||||
powered_by_gitbook: 'GitBook提供',
|
||||
sponsored_via_gitbook: 'GitBookスポンサー',
|
||||
switch_to_dark_theme: 'ダークテーマに切り替え',
|
||||
switch_to_light_theme: 'ライトテーマに切り替え',
|
||||
switch_to_system_theme: 'システムのテーマに切り替え',
|
||||
search: '検索',
|
||||
search_or_ask: '質問するか検索する',
|
||||
search_or_ask: '質問または検索',
|
||||
search_input_placeholder: 'コンテンツを検索',
|
||||
search_ask_input_placeholder: 'コンテンツを検索するか質問をする',
|
||||
search_no_results: '"${1}" の結果はありません。',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { TranslationLanguage } from './types';
|
||||
|
||||
export const nl: TranslationLanguage = {
|
||||
locale: 'nl',
|
||||
powered_by_gitbook: 'Powered by GitBook',
|
||||
sponsored_via_gitbook: 'Gesponsord door GitBook',
|
||||
switch_to_dark_theme: 'Schakel over naar donkere modus',
|
||||
switch_to_light_theme: 'Schakel over naar lichte modus',
|
||||
switch_to_system_theme: 'Schakel over naar systeemmodus',
|
||||
search: 'Zoeken',
|
||||
search_or_ask: 'Zoek of vraag',
|
||||
search_input_placeholder: 'Zoek inhoud',
|
||||
search_ask_input_placeholder: 'Zoek inhoud of stel een vraag',
|
||||
search_no_results: 'Geen resultaten voor "${1}".',
|
||||
search_scope_space: 'Alleen in ${1}',
|
||||
search_scope_all: 'Alle inhoud',
|
||||
search_ask: 'Vraag "${1}"',
|
||||
search_ask_sources: 'Bronnen',
|
||||
search_ask_sources_no_answer: 'Gerelateerde pagina’s',
|
||||
search_ask_no_answer:
|
||||
'Er kon geen antwoord op je vraag worden gevonden. Probeer je vraag anders te formuleren of wees specifieker.',
|
||||
search_ask_error: 'Er is iets misgegaan. Probeer het later opnieuw.',
|
||||
on_this_page: 'Op deze pagina',
|
||||
next_page: 'Volgende',
|
||||
previous_page: 'Vorige',
|
||||
page_last_modified: 'Laatst bijgewerkt ${1}',
|
||||
was_this_helpful: 'Was dit nuttig?',
|
||||
was_this_helpful_positive: 'Ja, het was nuttig!',
|
||||
was_this_helpful_neutral: 'Niet zeker',
|
||||
was_this_helpful_negative: 'Nee',
|
||||
was_this_helpful_thank_you: 'Bedankt!',
|
||||
annotation_button_label: 'Open annotatie',
|
||||
code_copied: 'Gekopieerd!',
|
||||
code_copy: 'Kopiëren',
|
||||
table_of_contents_button_label: 'Open inhoudsopgave',
|
||||
cookies_title: 'Cookies',
|
||||
cookies_prompt:
|
||||
'Deze site gebruikt cookies om zijn diensten te leveren en verkeer te analyseren. Door deze site te gebruiken, accepteer je de ${1}.',
|
||||
cookies_prompt_privacy: 'privacyverklaring',
|
||||
cookies_accept: 'Accepteren',
|
||||
cookies_reject: 'Weigeren',
|
||||
cookies_close: 'Sluiten',
|
||||
edit_on_git: 'Bewerk op ${1}',
|
||||
notfound_title: 'Pagina niet gevonden',
|
||||
notfound: 'De pagina die je zoekt, bestaat niet.',
|
||||
unexpected_error_title: 'Er is een fout opgetreden',
|
||||
unexpected_error: 'Sorry, er is een onverwachte fout opgetreden. Probeer het later opnieuw.',
|
||||
unexpected_error_retry: 'Opnieuw proberen',
|
||||
pdf_download: 'Exporteer als PDF',
|
||||
pdf_goback: 'Ga terug naar inhoud',
|
||||
pdf_print: 'Print of opslaan als PDF',
|
||||
pdf_page_of: '${1} van ${2}',
|
||||
pdf_mode_only_page: 'Alleen deze pagina',
|
||||
pdf_mode_all: 'Alle pagina’s',
|
||||
pdf_limit_reached: "Kon de PDF niet genereren voor ${1} pagina's, generatie gestopt bij ${2}.",
|
||||
pdf_limit_reached_continue: 'Verleng met ${1} extra pagina’s.',
|
||||
more: 'Meer',
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { TranslationLanguage } from './types';
|
||||
|
||||
export const no: TranslationLanguage = {
|
||||
locale: 'no',
|
||||
powered_by_gitbook: 'Drevet av GitBook',
|
||||
sponsored_via_gitbook: 'Sponset av GitBook',
|
||||
switch_to_dark_theme: 'Bytt til mørkt tema',
|
||||
switch_to_light_theme: 'Bytt til lyst tema',
|
||||
switch_to_system_theme: 'Bytt til systemtema',
|
||||
search: 'Søk',
|
||||
search_or_ask: 'Spør eller søk',
|
||||
search_input_placeholder: 'Søk i innhold',
|
||||
search_ask_input_placeholder: 'Søk i innhold eller still et spørsmål',
|
||||
search_no_results: 'Ingen resultater for "${1}".',
|
||||
search_scope_space: 'Kun i ${1}',
|
||||
search_scope_all: 'Alt innhold',
|
||||
search_ask: 'Spør "${1}"',
|
||||
search_ask_sources: 'Kilder',
|
||||
search_ask_sources_no_answer: 'Relaterte sider',
|
||||
search_ask_no_answer:
|
||||
'Et svar på spørsmålet ditt kunne ikke finnes. Prøv å omformulere det eller være mer spesifikk.',
|
||||
search_ask_error: 'Noe gikk galt. Vennligst prøv igjen senere.',
|
||||
on_this_page: 'På denne siden',
|
||||
next_page: 'Neste',
|
||||
previous_page: 'Forrige',
|
||||
page_last_modified: 'Sist oppdatert ${1}',
|
||||
was_this_helpful: 'Var dette nyttig?',
|
||||
was_this_helpful_positive: 'Ja, det var det!',
|
||||
was_this_helpful_neutral: 'Ikke sikker',
|
||||
was_this_helpful_negative: 'Nei',
|
||||
was_this_helpful_thank_you: 'Takk!',
|
||||
annotation_button_label: 'Åpne merknad',
|
||||
code_copied: 'Kopiert!',
|
||||
code_copy: 'Kopier',
|
||||
table_of_contents_button_label: 'Åpne innholdsfortegnelse',
|
||||
cookies_title: 'Informasjonskapsler',
|
||||
cookies_prompt:
|
||||
'Dette nettstedet bruker informasjonskapsler for å levere sine tjenester og analysere trafikk. Ved å bruke dette nettstedet aksepterer du ${1}.',
|
||||
cookies_prompt_privacy: 'personvernerklæringen',
|
||||
cookies_accept: 'Godta',
|
||||
cookies_reject: 'Avslå',
|
||||
cookies_close: 'Lukk',
|
||||
edit_on_git: 'Rediger på ${1}',
|
||||
notfound_title: 'Siden ble ikke funnet',
|
||||
notfound: 'Siden du leter etter eksisterer ikke.',
|
||||
unexpected_error_title: 'En feil oppstod',
|
||||
unexpected_error: 'Beklager, en uventet feil har oppstått. Vennligst prøv igjen senere.',
|
||||
unexpected_error_retry: 'Prøv igjen',
|
||||
pdf_download: 'Eksporter som PDF',
|
||||
pdf_goback: 'Gå tilbake til innhold',
|
||||
pdf_print: 'Skriv ut eller lagre som PDF',
|
||||
pdf_page_of: '${1} av ${2}',
|
||||
pdf_mode_only_page: 'Kun denne siden',
|
||||
pdf_mode_all: 'Alle sider',
|
||||
pdf_limit_reached: 'Kunne ikke generere PDF for ${1} sider, generering stoppet ved ${2}.',
|
||||
pdf_limit_reached_continue: 'Utvid med ${1} flere sider.',
|
||||
more: 'Mer',
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
export const pt_br = {
|
||||
locale: 'pt-br',
|
||||
powered_by_gitbook: 'Powered by GitBook',
|
||||
powered_by_gitbook: 'Fornecido por GitBook',
|
||||
sponsored_via_gitbook: 'Patrocinado por GitBook',
|
||||
switch_to_dark_theme: 'Mudar para modo escuro',
|
||||
switch_to_light_theme: 'Mudar para modo claro',
|
||||
switch_to_system_theme: 'Mudar para configuração do sistema',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TranslationLanguage } from './types';
|
||||
export const zh: TranslationLanguage = {
|
||||
locale: 'zh',
|
||||
powered_by_gitbook: '由 GitBook 提供支持',
|
||||
sponsored_via_gitbook: '通过 GitBook 赞助',
|
||||
switch_to_dark_theme: '切换到深色主题',
|
||||
switch_to_light_theme: '切换到浅色主题',
|
||||
switch_to_system_theme: '切换到系统主题',
|
||||
|
||||
@@ -71,6 +71,8 @@ async function fetchVisitorID(): Promise<string> {
|
||||
export function setCookiesTracking(enabled: boolean) {
|
||||
cookies.set(GRANTED_COOKIE, enabled ? 'yes' : 'no', {
|
||||
expires: 365,
|
||||
sameSite: 'none',
|
||||
secure: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+205
-178
@@ -72,12 +72,10 @@ export interface ContentTarget {
|
||||
const immutableCacheTtl_7days = {
|
||||
revalidateBefore: 24 * 60 * 60,
|
||||
ttl: 7 * 24 * 60 * 60,
|
||||
tags: [],
|
||||
};
|
||||
const immutableCacheTtl_1day = {
|
||||
revalidateBefore: 60 * 60,
|
||||
ttl: 24 * 60 * 60,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const apiSyncStorage = new AsyncLocalStorage<GitBookAPI>();
|
||||
@@ -143,9 +141,14 @@ export type PublishedContentWithCache =
|
||||
/**
|
||||
* Get a user by its ID.
|
||||
*/
|
||||
export const getUserById = cache(
|
||||
'api.getUserById',
|
||||
async (userId: string, options: CacheFunctionOptions) => {
|
||||
export const getUserById = cache({
|
||||
name: 'api.getUserById',
|
||||
tag: (userId) =>
|
||||
getAPICacheTag({
|
||||
tag: 'user',
|
||||
user: userId,
|
||||
}),
|
||||
get: async (userId: string, options: CacheFunctionOptions) => {
|
||||
try {
|
||||
const response = await api().users.getUserById(userId, {
|
||||
signal: options.signal,
|
||||
@@ -153,28 +156,31 @@ export const getUserById = cache(
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [],
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as GitBookAPIError).code === 404) {
|
||||
return {
|
||||
revalidateBefore: 60 * 60,
|
||||
data: null,
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a synced block by its ref.
|
||||
*/
|
||||
export const getSyncedBlockContent = cache(
|
||||
'api.getSyncedBlockContent',
|
||||
async (
|
||||
export const getSyncedBlockContent = cache({
|
||||
name: 'api.getSyncedBlockContent',
|
||||
tag: (apiToken, organizationId, syncedBlockId) =>
|
||||
getAPICacheTag({
|
||||
tag: 'synced-block',
|
||||
syncedBlock: syncedBlockId,
|
||||
}),
|
||||
get: async (
|
||||
apiToken: string,
|
||||
organizationId: string,
|
||||
syncedBlockId: string,
|
||||
@@ -191,38 +197,36 @@ export const getSyncedBlockContent = cache(
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [
|
||||
getAPICacheTag({
|
||||
tag: 'synced-block',
|
||||
syncedBlock: syncedBlockId,
|
||||
}),
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as GitBookAPIError).code === 404) {
|
||||
return {
|
||||
revalidateBefore: 60 * 60,
|
||||
data: null,
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
// We don't cache apiToken as it's not a stable key
|
||||
extractArgs: (args) => [args[1], args[2]],
|
||||
},
|
||||
);
|
||||
// We don't cache apiToken as it's not a stable key
|
||||
getKeyArgs: (args) => [args[1], args[2]],
|
||||
});
|
||||
/**
|
||||
* Resolve a URL to the content to render.
|
||||
*/
|
||||
export const getPublishedContentByUrl = cache(
|
||||
'api.getPublishedContentByUrl.v3',
|
||||
async (url: string, visitorAuthToken: string | undefined, options: CacheFunctionOptions) => {
|
||||
const parsedURL = new URL(url);
|
||||
|
||||
export const getPublishedContentByUrl = cache({
|
||||
name: 'api.getPublishedContentByUrl.v3',
|
||||
tag: (url) =>
|
||||
getAPICacheTag({
|
||||
tag: 'url',
|
||||
hostname: new URL(url).hostname,
|
||||
}),
|
||||
get: async (
|
||||
url: string,
|
||||
visitorAuthToken: string | undefined,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
try {
|
||||
const response = await api().urls.getPublishedContentByUrl(
|
||||
{
|
||||
@@ -243,12 +247,6 @@ export const getPublishedContentByUrl = cache(
|
||||
cacheTags: parsed.tags,
|
||||
};
|
||||
return {
|
||||
tags: [
|
||||
getAPICacheTag({
|
||||
tag: 'url',
|
||||
hostname: parsedURL.hostname,
|
||||
}),
|
||||
],
|
||||
ttl: parsed.ttl,
|
||||
data,
|
||||
};
|
||||
@@ -264,26 +262,21 @@ export const getPublishedContentByUrl = cache(
|
||||
// Cache errors for max 10 minutes in case the user is making changes to its content configuration
|
||||
// and to avoid caching too many entries when being spammed by botss
|
||||
ttl: 60 * 10,
|
||||
tags: [
|
||||
getAPICacheTag({
|
||||
tag: 'url',
|
||||
hostname: parsedURL.hostname,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a space by its ID.
|
||||
*/
|
||||
export const getSpace = cache(
|
||||
'api.getSpace',
|
||||
async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => {
|
||||
export const getSpace = cache({
|
||||
name: 'api.getSpace',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => {
|
||||
const response = await api().spaces.getSpaceById(
|
||||
spaceId,
|
||||
{
|
||||
@@ -296,47 +289,47 @@ export const getSpace = cache(
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [getAPICacheTag({ tag: 'space', space: spaceId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a change request by its ID.
|
||||
*/
|
||||
export const getChangeRequest = cache(
|
||||
'api.getChangeRequest',
|
||||
async (spaceId: string, changeRequestId: string, options: CacheFunctionOptions) => {
|
||||
export const getChangeRequest = cache({
|
||||
name: 'api.getChangeRequest',
|
||||
tag: (spaceId, changeRequestId) =>
|
||||
getAPICacheTag({ tag: 'change-request', space: spaceId, changeRequest: changeRequestId }),
|
||||
get: async (spaceId: string, changeRequestId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().spaces.getChangeRequestById(spaceId, changeRequestId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
// We don't cache for long as we currently don't invalidate change-request cache
|
||||
// and it's only used for preview where perfs are not critical
|
||||
// and it's only used for preview where perfs are not critical
|
||||
ttl: 60 * 60,
|
||||
revalidateBefore: 10 * 60,
|
||||
tags: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* List the scripts to load for the space.
|
||||
*/
|
||||
export const getSpaceIntegrationScripts = cache(
|
||||
'api.getSpaceIntegrationScripts',
|
||||
async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
export const getSpaceIntegrationScripts = cache({
|
||||
name: 'api.getSpaceIntegrationScripts',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().spaces.listSpaceIntegrationScripts(spaceId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [getAPICacheTag({ tag: 'space', space: spaceId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
interface GetRevisionOptions {
|
||||
/**
|
||||
@@ -351,9 +344,11 @@ interface GetRevisionOptions {
|
||||
/**
|
||||
* Get a revision by its ID.
|
||||
*/
|
||||
export const getRevision = cache(
|
||||
'api.getRevision.v2',
|
||||
async (
|
||||
export const getRevision = cache({
|
||||
name: 'api.getRevision.v2',
|
||||
tag: (spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
get: async (
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
fetchOptions: GetRevisionOptions,
|
||||
@@ -376,17 +371,17 @@ export const getRevision = cache(
|
||||
fetchOptions.metadata ? immutableCacheTtl_7days : immutableCacheTtl_1day,
|
||||
);
|
||||
},
|
||||
{
|
||||
extractArgs: (args) => [args[0], args[1]],
|
||||
},
|
||||
);
|
||||
getKeyArgs: (args) => [args[0], args[1]],
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all the pages in a revision of a space.
|
||||
*/
|
||||
export const getRevisionPages = cache(
|
||||
'api.getRevisionPages.v4',
|
||||
async (
|
||||
export const getRevisionPages = cache({
|
||||
name: 'api.getRevisionPages.v4',
|
||||
tag: (spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
get: async (
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
fetchOptions: GetRevisionOptions,
|
||||
@@ -409,17 +404,17 @@ export const getRevisionPages = cache(
|
||||
data: response.data.pages,
|
||||
});
|
||||
},
|
||||
{
|
||||
extractArgs: (args) => [args[0], args[1]],
|
||||
},
|
||||
);
|
||||
getKeyArgs: (args) => [args[0], args[1]],
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a revision page by its path
|
||||
*/
|
||||
export const getRevisionPageByPath = cache(
|
||||
'api.getRevisionPageByPath.v3',
|
||||
async (
|
||||
export const getRevisionPageByPath = cache({
|
||||
name: 'api.getRevisionPageByPath.v3',
|
||||
tag: (spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
get: async (
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
pagePath: string,
|
||||
@@ -453,15 +448,22 @@ export const getRevisionPageByPath = cache(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve a file by its ID.
|
||||
* It should not be used directly, use `getRevisionFile` instead.
|
||||
*/
|
||||
const getRevisionFileById = cache(
|
||||
'api.getRevisionFile.v3',
|
||||
async (spaceId: string, revisionId: string, fileId: string, options: CacheFunctionOptions) => {
|
||||
const getRevisionFileById = cache({
|
||||
name: 'api.getRevisionFile.v3',
|
||||
tag: (spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
get: async (
|
||||
spaceId: string,
|
||||
revisionId: string,
|
||||
fileId: string,
|
||||
options: CacheFunctionOptions,
|
||||
) => {
|
||||
try {
|
||||
const response = await (async () => {
|
||||
return api().spaces.getFileInRevisionById(
|
||||
@@ -487,15 +489,17 @@ const getRevisionFileById = cache(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all the files in a revision of a space.
|
||||
* It should not be used directly, use `getRevisionFile` instead.
|
||||
*/
|
||||
const getRevisionAllFiles = cache(
|
||||
'api.getRevisionAllFiles.v2',
|
||||
async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => {
|
||||
const getRevisionAllFiles = cache({
|
||||
name: 'api.getRevisionAllFiles.v2',
|
||||
tag: (spaceId, revisionId) =>
|
||||
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
|
||||
get: async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => {
|
||||
const response = await getAll(
|
||||
(params) =>
|
||||
api().spaces.listFilesInRevisionById(
|
||||
@@ -522,10 +526,8 @@ const getRevisionAllFiles = cache(
|
||||
|
||||
return cacheResponse(response, { ...immutableCacheTtl_7days, data: files });
|
||||
},
|
||||
{
|
||||
timeout: 60 * 1000,
|
||||
},
|
||||
);
|
||||
timeout: 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve a file by its ID.
|
||||
@@ -583,9 +585,11 @@ export const getRevisionFile = batch<[string, string, string], RevisionFile | nu
|
||||
/**
|
||||
* Get a document by its ID.
|
||||
*/
|
||||
export const getDocument = cache(
|
||||
'api.getDocument.v2',
|
||||
async (spaceId: string, documentId: string, options: CacheFunctionOptions) => {
|
||||
export const getDocument = cache({
|
||||
name: 'api.getDocument.v2',
|
||||
tag: (spaceId, documentId) =>
|
||||
getAPICacheTag({ tag: 'document', space: spaceId, document: documentId }),
|
||||
get: async (spaceId: string, documentId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().spaces.getDocumentById(
|
||||
spaceId,
|
||||
documentId,
|
||||
@@ -599,20 +603,19 @@ export const getDocument = cache(
|
||||
);
|
||||
return cacheResponse(response, immutableCacheTtl_7days);
|
||||
},
|
||||
{
|
||||
// Temporarily allow for a longer timeout than the default 10s
|
||||
// because GitBook's API currently re-normalizes all documents
|
||||
// and it can take more than 10s...
|
||||
timeout: 20 * 1000,
|
||||
},
|
||||
);
|
||||
// Temporarily allow for a longer timeout than the default 10s
|
||||
// because GitBook's API currently re-normalizes all documents
|
||||
// and it can take more than 10s...
|
||||
timeout: 20 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the customization settings for a site-space from the API.
|
||||
*/
|
||||
const getSiteSpaceCustomizationFromAPI = cache(
|
||||
'api.getSiteSpaceCustomizationById',
|
||||
async (
|
||||
const getSiteSpaceCustomizationFromAPI = cache({
|
||||
name: 'api.getSiteSpaceCustomizationById',
|
||||
tag: (organizationId, siteId, siteSpaceId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
get: async (
|
||||
organizationId: string,
|
||||
siteId: string,
|
||||
siteSpaceId: string,
|
||||
@@ -630,22 +633,17 @@ const getSiteSpaceCustomizationFromAPI = cache(
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [
|
||||
getAPICacheTag({
|
||||
tag: 'site',
|
||||
site: siteId,
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the customization settings for a site from the API.
|
||||
*/
|
||||
const getSiteCustomizationFromAPI = cache(
|
||||
'api.getSiteCustomizationById',
|
||||
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
const getSiteCustomizationFromAPI = cache({
|
||||
name: 'api.getSiteCustomizationById',
|
||||
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().orgs.getSiteCustomizationById(
|
||||
organizationId,
|
||||
siteId,
|
||||
@@ -657,15 +655,9 @@ const getSiteCustomizationFromAPI = cache(
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [
|
||||
getAPICacheTag({
|
||||
tag: 'site',
|
||||
site: siteId,
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the customization settings for a site space from the API.
|
||||
@@ -698,26 +690,27 @@ async function getSiteCustomization(args: {
|
||||
/**
|
||||
* Get the infos about a site by its ID.
|
||||
*/
|
||||
export const getSite = cache(
|
||||
'api.getSite',
|
||||
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
export const getSite = cache({
|
||||
name: 'api.getSite',
|
||||
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().orgs.getSiteById(organizationId, siteId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [getAPICacheTag({ tag: 'site', site: siteId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* List all the site-spaces variants published in a site.
|
||||
*/
|
||||
export const getSiteSpaces = cache(
|
||||
'api.getSiteSpaces',
|
||||
async (
|
||||
export const getSiteSpaces = cache({
|
||||
name: 'api.getSiteSpaces',
|
||||
tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
get: async (
|
||||
args: {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
@@ -744,27 +737,26 @@ export const getSiteSpaces = cache(
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
data: response.data.items.map((siteSpace) => siteSpace),
|
||||
tags: [getAPICacheTag({ tag: 'site', site: args.siteId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* List the scripts to load for the site.
|
||||
*/
|
||||
export const getSiteIntegrationScripts = cache(
|
||||
'api.getSiteIntegrationScripts',
|
||||
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
export const getSiteIntegrationScripts = cache({
|
||||
name: 'api.getSiteIntegrationScripts',
|
||||
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().orgs.listSiteIntegrationScripts(organizationId, siteId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [getAPICacheTag({ tag: 'site', site: siteId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch all the data to render the current site at once.
|
||||
@@ -835,19 +827,19 @@ export async function getCurrentSiteCustomization(args: {
|
||||
/**
|
||||
* Get the customization settings for a space from the API.
|
||||
*/
|
||||
export const getSpaceCustomizationFromAPI = cache(
|
||||
'api.getSpaceCustomization',
|
||||
async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
export const getSpaceCustomizationFromAPI = cache({
|
||||
name: 'api.getSpaceCustomization',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().spaces.getSpacePublishingCustomizationById(spaceId, {
|
||||
signal: options.signal,
|
||||
...noCacheFetchOptions,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [getAPICacheTag({ tag: 'space', space: spaceId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the customization settings for a space from the API.
|
||||
@@ -876,26 +868,27 @@ export async function getSpaceCustomization(spaceId: string): Promise<Customizat
|
||||
/**
|
||||
* Get the infos about a collection by its ID.
|
||||
*/
|
||||
export const getCollection = cache(
|
||||
'api.getCollection',
|
||||
async (collectionId: string, options: CacheFunctionOptions) => {
|
||||
export const getCollection = cache({
|
||||
name: 'api.getCollection',
|
||||
tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }),
|
||||
get: async (collectionId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().collections.getCollectionById(collectionId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [getAPICacheTag({ tag: 'collection', collection: collectionId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* List all the spaces variants published in a collection.
|
||||
*/
|
||||
export const getCollectionSpaces = cache(
|
||||
'api.getCollectionSpaces',
|
||||
async (collectionId: string, options: CacheFunctionOptions) => {
|
||||
export const getCollectionSpaces = cache({
|
||||
name: 'api.getCollectionSpaces',
|
||||
tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }),
|
||||
get: async (collectionId: string, options: CacheFunctionOptions) => {
|
||||
const response = await getAll((params) =>
|
||||
api().collections.listSpacesInCollectionById(collectionId, params, {
|
||||
...noCacheFetchOptions,
|
||||
@@ -908,10 +901,9 @@ export const getCollectionSpaces = cache(
|
||||
data: response.data.items.filter(
|
||||
(space) => space.visibility === ContentVisibility.InCollection,
|
||||
),
|
||||
tags: [getAPICacheTag({ tag: 'collection', collection: collectionId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch all the data to render a space at once.
|
||||
@@ -979,9 +971,10 @@ export async function getSpaceLayoutData(spaceId: string) {
|
||||
/**
|
||||
* Search content in a space.
|
||||
*/
|
||||
export const searchSpaceContent = cache(
|
||||
'api.searchSpaceContent',
|
||||
async (
|
||||
export const searchSpaceContent = cache({
|
||||
name: 'api.searchSpaceContent',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (
|
||||
spaceId: string,
|
||||
/** The revision ID is used as a cache bust key, to avoid revalidating lot of cache entries by tags */
|
||||
revisionId: string,
|
||||
@@ -996,18 +989,17 @@ export const searchSpaceContent = cache(
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
tags: [],
|
||||
});
|
||||
return cacheResponse(response);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Search content accross all spaces in a parent (site or collection).
|
||||
*/
|
||||
export const searchParentContent = cache(
|
||||
'api.searchParentContent',
|
||||
async (parentId: string, query: string, options: CacheFunctionOptions) => {
|
||||
export const searchParentContent = cache({
|
||||
name: 'api.searchParentContent',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (parentId: string, query: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().search.searchContent(
|
||||
{ query },
|
||||
{
|
||||
@@ -1017,17 +1009,17 @@ export const searchParentContent = cache(
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
ttl: 60 * 60,
|
||||
tags: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Search content in a Site or specific SiteSpaces.
|
||||
*/
|
||||
export const searchSiteContent = cache(
|
||||
'api.searchSiteContent',
|
||||
async (
|
||||
export const searchSiteContent = cache({
|
||||
name: 'api.searchSiteContent',
|
||||
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
|
||||
get: async (
|
||||
organizationId: string,
|
||||
siteId: string,
|
||||
query: string,
|
||||
@@ -1052,33 +1044,32 @@ export const searchSiteContent = cache(
|
||||
|
||||
return cacheResponse(response, {
|
||||
ttl: 60 * 60,
|
||||
tags: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a list of recommended questions in a space.
|
||||
*/
|
||||
export const getRecommendedQuestionsInSpace = cache(
|
||||
'api.getRecommendedQuestionsInSpace',
|
||||
async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
export const getRecommendedQuestionsInSpace = cache({
|
||||
name: 'api.getRecommendedQuestionsInSpace',
|
||||
tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
|
||||
get: async (spaceId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().spaces.getRecommendedQuestionsInSpace(spaceId, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
return cacheResponse(response, {
|
||||
tags: [],
|
||||
});
|
||||
return cacheResponse(response);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Render an integration contentkit UI
|
||||
*/
|
||||
export const renderIntegrationUi = cache(
|
||||
'api.renderIntegrationUi',
|
||||
async (
|
||||
export const renderIntegrationUi = cache({
|
||||
name: 'api.renderIntegrationUi',
|
||||
tag: (integrationName) => getAPICacheTag({ tag: 'integration', integration: integrationName }),
|
||||
get: async (
|
||||
integrationName: string,
|
||||
request: RequestRenderIntegrationUI,
|
||||
options: CacheFunctionOptions,
|
||||
@@ -1091,21 +1082,47 @@ export const renderIntegrationUi = cache(
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
tags: [],
|
||||
});
|
||||
return cacheResponse(response);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a cache tag for the API.
|
||||
*/
|
||||
export function getAPICacheTag(
|
||||
spec: // All data related to a space
|
||||
spec: // All data related to a user
|
||||
| {
|
||||
tag: 'user';
|
||||
user: string;
|
||||
}
|
||||
// All data related to a space
|
||||
| {
|
||||
tag: 'space';
|
||||
space: string;
|
||||
}
|
||||
// All data related to an integration
|
||||
| {
|
||||
tag: 'integration';
|
||||
integration: string;
|
||||
}
|
||||
// All data related to a change request
|
||||
| {
|
||||
tag: 'change-request';
|
||||
space: string;
|
||||
changeRequest: string;
|
||||
}
|
||||
// Immutable data related to a revision
|
||||
| {
|
||||
tag: 'revision';
|
||||
space: string;
|
||||
revision: string;
|
||||
}
|
||||
// Immutable data related to a document
|
||||
| {
|
||||
tag: 'document';
|
||||
space: string;
|
||||
document: string;
|
||||
}
|
||||
// All data related to the URL of a content
|
||||
| {
|
||||
tag: 'url';
|
||||
@@ -1128,16 +1145,26 @@ export function getAPICacheTag(
|
||||
},
|
||||
): string {
|
||||
switch (spec.tag) {
|
||||
case 'user':
|
||||
return `user:${spec.user}`;
|
||||
case 'url':
|
||||
return `url:${spec.hostname}`;
|
||||
case 'space':
|
||||
return `space:${spec.space}`;
|
||||
case 'change-request':
|
||||
return `space:${spec.space}:change-request:${spec.changeRequest}`;
|
||||
case 'revision':
|
||||
return `space:${spec.space}:revision:${spec.revision}`;
|
||||
case 'document':
|
||||
return `space:${spec.space}:document:${spec.document}`;
|
||||
case 'collection':
|
||||
return `collection:${spec.collection}`;
|
||||
case 'synced-block':
|
||||
return `synced-block:${spec.syncedBlock}`;
|
||||
case 'site':
|
||||
return `site:${spec.site}`;
|
||||
case 'integration':
|
||||
return `integration:${spec.integration}`;
|
||||
default:
|
||||
assertNever(spec);
|
||||
}
|
||||
|
||||
+2
-4
@@ -1,18 +1,16 @@
|
||||
import { cloudflareCache } from './cloudflare-cache';
|
||||
import { cloudflareDOCache } from './cloudflare-do';
|
||||
import { cloudflareKVCache } from './cloudflare-kv';
|
||||
import { memoryCache } from './memory';
|
||||
import { redisCache } from './redis';
|
||||
|
||||
export const cacheBackends = [
|
||||
// Cache local to the process
|
||||
// (can't be globally purged or shared between processes)
|
||||
memoryCache,
|
||||
// Global cache shared between all processes
|
||||
// with proper replication and invalidation
|
||||
// redisCache, // Disabled as we investigate high amount of fetch requests
|
||||
// Cache local to the datacenter
|
||||
// It can't be purged globally but it's faster
|
||||
cloudflareCache,
|
||||
// Cache global, but with slow replication
|
||||
cloudflareKVCache,
|
||||
cloudflareDOCache,
|
||||
];
|
||||
|
||||
+10
-6
@@ -15,12 +15,16 @@ describe('cache', () => {
|
||||
testId += 1;
|
||||
getTtl = () => 1000;
|
||||
|
||||
fn = cache(`cache-${testId}`, async (arg: string, options: CacheFunctionOptions) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
return {
|
||||
data: impl(arg),
|
||||
ttl: getTtl(),
|
||||
};
|
||||
fn = cache({
|
||||
name: `cache-${testId}`,
|
||||
tag: (arg) => 'test',
|
||||
get: async (arg: string, options: CacheFunctionOptions) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
return {
|
||||
data: impl(arg),
|
||||
ttl: getTtl(),
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+43
-37
@@ -43,11 +43,26 @@ export interface CacheResult<Result> {
|
||||
* Time before ttl where the cache should be revalidated (in seconds).
|
||||
*/
|
||||
revalidateBefore?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags to associate with the cache entry.
|
||||
*/
|
||||
tags?: string[];
|
||||
export interface CacheDefinition<Args extends any[], Result> {
|
||||
/** Unique name for the cache */
|
||||
name: string;
|
||||
|
||||
/** Tag to associate to the entry */
|
||||
tag?: (...args: Args) => string;
|
||||
|
||||
/** Filter the arguments that should be taken into consideration for the cache key */
|
||||
getKeyArgs?: (args: Args) => any[];
|
||||
|
||||
/** Default ttl (in seconds) */
|
||||
defaultTtl?: number;
|
||||
|
||||
/** When a request to the underlying resource will timeout. */
|
||||
timeout?: number;
|
||||
|
||||
/** Function to get the value */
|
||||
get: (...args: [...Args, CacheFunctionOptions]) => Promise<CacheResult<Result>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,21 +70,11 @@ export interface CacheResult<Result> {
|
||||
* We don't use the next.js cache because it has a 2MB limit.
|
||||
*/
|
||||
export function cache<Args extends any[], Result>(
|
||||
cacheName: string,
|
||||
fn: (...args: [...Args, CacheFunctionOptions]) => Promise<CacheResult<Result>>,
|
||||
options: {
|
||||
/** Filter the arguments that should be taken into consideration for caching */
|
||||
extractArgs?: (args: Args) => any[];
|
||||
|
||||
/** Default ttl (in seconds) */
|
||||
defaultTtl?: number;
|
||||
|
||||
/** When a request to the underlying resource will timeout. */
|
||||
timeout?: number;
|
||||
} = {},
|
||||
cacheDef: CacheDefinition<Args, Result>,
|
||||
): CacheFunction<Args, Result> {
|
||||
// We stop everything after 10s to avoid pending requests
|
||||
const timeout = options.timeout ?? 1000 * 10;
|
||||
const timeout = cacheDef.timeout ?? 1000 * 10;
|
||||
const defaultTtl = cacheDef.defaultTtl ?? 60 * 60 * 24;
|
||||
|
||||
const revalidate = singletonMap(
|
||||
async (key: string, signal: AbortSignal | undefined, ...args: Args) => {
|
||||
@@ -80,19 +85,18 @@ export function cache<Args extends any[], Result>(
|
||||
},
|
||||
async (span) => {
|
||||
// Fetch upstream
|
||||
const result = await fn(...args, { signal });
|
||||
const result = await cacheDef.get(...args, { signal });
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const setAt = Date.now();
|
||||
const expiresAt =
|
||||
setAt + (result.ttl ?? options.defaultTtl ?? 60 * 60 * 24) * 1000;
|
||||
const expiresAt = setAt + (result.ttl ?? defaultTtl) * 1000;
|
||||
|
||||
const cacheEntry: CacheEntry = {
|
||||
data: result.data,
|
||||
meta: {
|
||||
key,
|
||||
cache: cacheName,
|
||||
tags: result.tags ?? [],
|
||||
cache: cacheDef.name,
|
||||
tag: cacheDef.tag?.(...args),
|
||||
setAt,
|
||||
expiresAt,
|
||||
revalidatesAt: result.revalidateBefore
|
||||
@@ -106,7 +110,7 @@ export function cache<Args extends any[], Result>(
|
||||
|
||||
// Write it to the cache
|
||||
if (result.ttl && result.ttl > 0) {
|
||||
await waitUntil(setCacheEntry(key, cacheEntry));
|
||||
await waitUntil(setCacheEntry(cacheEntry));
|
||||
}
|
||||
|
||||
return cacheEntry;
|
||||
@@ -122,9 +126,10 @@ export function cache<Args extends any[], Result>(
|
||||
let fetchDuration = 0;
|
||||
|
||||
let result: readonly [CacheEntry, string] | null = null;
|
||||
const tag = cacheDef.tag?.(...args);
|
||||
|
||||
// Try the memory backend, independently of the other backends as it doesn't have a network cost
|
||||
const memoryEntry = await memoryCache.get(key);
|
||||
const memoryEntry = await memoryCache.get({ key, tag });
|
||||
if (memoryEntry) {
|
||||
span.setAttribute('memory', true);
|
||||
result = [memoryEntry, 'memory'] as const;
|
||||
@@ -132,7 +137,7 @@ export function cache<Args extends any[], Result>(
|
||||
result = await race(
|
||||
cacheBackends,
|
||||
async (backend, { signal }) => {
|
||||
const entry = await backend.get(key, { signal });
|
||||
const entry = await backend.get({ key, tag }, { signal });
|
||||
return entry ? ([entry, backend.name] as const) : null;
|
||||
},
|
||||
{
|
||||
@@ -184,7 +189,7 @@ export function cache<Args extends any[], Result>(
|
||||
(backend) =>
|
||||
backend.name !== backendName && backend.replication === 'local',
|
||||
)
|
||||
.map((backend) => backend.set(key, savedEntry)),
|
||||
.map((backend) => backend.set(savedEntry)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -214,8 +219,8 @@ export function cache<Args extends any[], Result>(
|
||||
const cacheFn = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => {
|
||||
const [args, { signal }] = extractCacheFunctionOptions<Args>(rawArgs);
|
||||
|
||||
const cacheArgs = options.extractArgs ? options.extractArgs(args) : args;
|
||||
const key = getCacheKey(cacheName, cacheArgs);
|
||||
const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
|
||||
const key = getCacheKey(cacheDef.name, cacheArgs);
|
||||
|
||||
return await trace(
|
||||
{
|
||||
@@ -231,17 +236,18 @@ export function cache<Args extends any[], Result>(
|
||||
|
||||
cacheFn.revalidate = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => {
|
||||
const [args, { signal }] = extractCacheFunctionOptions<Args>(rawArgs);
|
||||
const cacheArgs = options.extractArgs ? options.extractArgs(args) : args;
|
||||
const key = getCacheKey(cacheName, cacheArgs);
|
||||
const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
|
||||
const key = getCacheKey(cacheDef.name, cacheArgs);
|
||||
|
||||
await revalidate(key, signal, ...args);
|
||||
};
|
||||
|
||||
cacheFn.hasInMemory = async (...args: Args) => {
|
||||
const cacheArgs = options.extractArgs ? options.extractArgs(args) : args;
|
||||
const key = getCacheKey(cacheName, cacheArgs);
|
||||
const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
|
||||
const key = getCacheKey(cacheDef.name, cacheArgs);
|
||||
const tag = cacheDef.tag?.(...args);
|
||||
|
||||
const memoryEntry = await memoryCache.get(key);
|
||||
const memoryEntry = await memoryCache.get({ key, tag });
|
||||
if (memoryEntry) {
|
||||
return true;
|
||||
}
|
||||
@@ -250,7 +256,7 @@ export function cache<Args extends any[], Result>(
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
registeredCaches.set(cacheName, cacheFn);
|
||||
registeredCaches.set(cacheDef.name, cacheFn);
|
||||
|
||||
return cacheFn;
|
||||
}
|
||||
@@ -289,14 +295,14 @@ function hashValue(arg: any): string {
|
||||
return JSON.stringify(arg);
|
||||
}
|
||||
|
||||
async function setCacheEntry(key: string, entry: CacheEntry) {
|
||||
async function setCacheEntry(entry: CacheEntry) {
|
||||
return await trace(
|
||||
{
|
||||
operation: `cache.setCacheEntry`,
|
||||
name: key,
|
||||
name: entry.meta.key,
|
||||
},
|
||||
async () => {
|
||||
await Promise.all(cacheBackends.map((backend) => backend.set(key, entry)));
|
||||
await Promise.all(cacheBackends.map((backend) => backend.set(entry)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+16
-14
@@ -19,7 +19,7 @@ const cacheVersion = 2;
|
||||
export const cloudflareCache: CacheBackend = {
|
||||
name: 'cloudflare',
|
||||
replication: 'local',
|
||||
async get(key, options) {
|
||||
async get(entry, options) {
|
||||
const cache = getCache();
|
||||
if (!cache) {
|
||||
return null;
|
||||
@@ -27,10 +27,10 @@ export const cloudflareCache: CacheBackend = {
|
||||
return trace(
|
||||
{
|
||||
operation: `cloudflareCache.get`,
|
||||
name: key,
|
||||
name: entry.key,
|
||||
},
|
||||
async (span) => {
|
||||
const cacheKey = await serializeKey(key);
|
||||
const cacheKey = await serializeKey(entry.key);
|
||||
const response = await cache.match(cacheKey);
|
||||
span.setAttribute('hit', !!response);
|
||||
|
||||
@@ -39,32 +39,32 @@ export const cloudflareCache: CacheBackend = {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = await deserializeEntry(response);
|
||||
return entry;
|
||||
const cacheEntry = await deserializeEntry(response);
|
||||
return cacheEntry;
|
||||
},
|
||||
);
|
||||
},
|
||||
async set(key, entry) {
|
||||
async set(entry) {
|
||||
const cache = getCache();
|
||||
if (cache) {
|
||||
await trace(
|
||||
{
|
||||
operation: `cloudflareCache.set`,
|
||||
name: key,
|
||||
name: entry.meta.key,
|
||||
},
|
||||
async () => {
|
||||
const cacheKey = await serializeKey(key);
|
||||
const cacheKey = await serializeKey(entry.meta.key);
|
||||
await cache.put(cacheKey, serializeEntry(entry));
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
async del(keys) {
|
||||
async del(entries) {
|
||||
const cache = getCache();
|
||||
if (cache) {
|
||||
await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
const cacheKey = await serializeKey(key);
|
||||
entries.map(async (entry) => {
|
||||
const cacheKey = await serializeKey(entry.key);
|
||||
await cache.delete(cacheKey);
|
||||
}),
|
||||
);
|
||||
@@ -72,8 +72,7 @@ export const cloudflareCache: CacheBackend = {
|
||||
},
|
||||
async revalidateTags(tags) {
|
||||
return {
|
||||
keys: [],
|
||||
metas: [],
|
||||
entries: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -103,7 +102,10 @@ async function serializeKey(key: string): Promise<string> {
|
||||
function serializeEntry(entry: CacheEntry): WorkerResponse {
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', 'application/json');
|
||||
const cacheTags = ['gitbook-open', ...entry.meta.tags];
|
||||
const cacheTags = ['gitbook-open'];
|
||||
if (entry.meta.tag) {
|
||||
cacheTags.push(entry.meta.tag);
|
||||
}
|
||||
|
||||
const maxAge = getCacheMaxAge(
|
||||
entry.meta,
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { CacheObjectStub, CacheLocationId } from '@gitbook/cache-do/api';
|
||||
|
||||
import { CacheBackend, CacheEntry, CacheEntryLookup } from './types';
|
||||
import { trace } from '../tracing';
|
||||
|
||||
/**
|
||||
* Cache implementation using the custom Cloudflare Durable Object.
|
||||
*/
|
||||
export const cloudflareDOCache: CacheBackend = {
|
||||
name: 'cloudflare-do',
|
||||
replication: 'global',
|
||||
async get(entry, options) {
|
||||
const { key, tag } = entry;
|
||||
if (!tag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trace(
|
||||
{
|
||||
operation: `cloudflareDO.get`,
|
||||
name: entry.key,
|
||||
},
|
||||
async (span) => {
|
||||
const stub = await getStub(tag);
|
||||
if (!stub) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await stub.get<CacheEntry>(key)) ?? null;
|
||||
},
|
||||
);
|
||||
},
|
||||
async set(entry) {
|
||||
const { key, tag } = entry.meta;
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
|
||||
return trace(
|
||||
{
|
||||
operation: `cloudflareDO.set`,
|
||||
name: key,
|
||||
},
|
||||
async () => {
|
||||
const stub = await getStub(tag);
|
||||
if (!stub) {
|
||||
return;
|
||||
}
|
||||
|
||||
await stub.set<CacheEntry>(key, entry, entry.meta.expiresAt);
|
||||
},
|
||||
);
|
||||
},
|
||||
async del(entries) {
|
||||
// We don't need to directly delete entries from the Cloudflare DO cache.
|
||||
},
|
||||
async revalidateTags(tags) {
|
||||
const entries: CacheEntryLookup[] = [];
|
||||
|
||||
await Promise.all(
|
||||
tags.map(async (tag) => {
|
||||
const stub = await getStub(tag);
|
||||
if (!stub) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = await stub.purge();
|
||||
keys.forEach((key) => {
|
||||
entries.push({ key, tag });
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return { entries };
|
||||
},
|
||||
};
|
||||
|
||||
const globalStubs = new WeakMap<object, Map<string, CacheObjectStub>>();
|
||||
|
||||
async function getStub(tag: string): Promise<CacheObjectStub | null> {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We lazy-load the next-on-pages package to avoid errors when running tests because of 'server-only'.
|
||||
const { getOptionalRequestContext } = await import('@cloudflare/next-on-pages');
|
||||
const cloudflare = getOptionalRequestContext();
|
||||
if (!cloudflare || !cloudflare.env.CACHE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestStubs = globalStubs.get(cloudflare.cf) ?? new Map();
|
||||
globalStubs.set(cloudflare.cf, requestStubs);
|
||||
|
||||
const locationId: CacheLocationId = cloudflare.cf.continent ?? 'NA';
|
||||
const stub =
|
||||
requestStubs.get(tag) ?? new CacheObjectStub(cloudflare.env.CACHE, locationId, tag);
|
||||
requestStubs.set(tag, stub);
|
||||
|
||||
return stub;
|
||||
}
|
||||
+16
-26
@@ -17,7 +17,7 @@ interface KVTagMetadata {
|
||||
export const cloudflareKVCache: CacheBackend = {
|
||||
name: 'cloudflare-kv',
|
||||
replication: 'global',
|
||||
async get(key, options) {
|
||||
async get({ key }, options) {
|
||||
const kv = await getKVNamespace();
|
||||
if (!kv) {
|
||||
return null;
|
||||
@@ -42,7 +42,7 @@ export const cloudflareKVCache: CacheBackend = {
|
||||
},
|
||||
);
|
||||
},
|
||||
async set(key, entry) {
|
||||
async set(entry) {
|
||||
const kv = await getKVNamespace();
|
||||
if (!kv) {
|
||||
return;
|
||||
@@ -51,7 +51,7 @@ export const cloudflareKVCache: CacheBackend = {
|
||||
return trace(
|
||||
{
|
||||
operation: `cloudflareKV.set`,
|
||||
name: key,
|
||||
name: entry.meta.key,
|
||||
},
|
||||
async () => {
|
||||
const secondsFromNow = getCacheMaxAge(entry.meta, 0, 60 * 60 * 24);
|
||||
@@ -61,54 +61,45 @@ export const cloudflareKVCache: CacheBackend = {
|
||||
return;
|
||||
}
|
||||
|
||||
const kvKey = getValueKey(key);
|
||||
const kvKey = getValueKey(entry.meta.key);
|
||||
await kv.put(kvKey, JSON.stringify(entry), {
|
||||
expirationTtl: secondsFromNow,
|
||||
});
|
||||
|
||||
if (entry.meta.tags.length > 0) {
|
||||
if (entry.meta.tag) {
|
||||
const metadata: KVTagMetadata = {
|
||||
meta: entry.meta,
|
||||
};
|
||||
const jsonMetadata = JSON.stringify(metadata);
|
||||
const tagKey = getTagKey(entry.meta.tag, entry.meta.key);
|
||||
|
||||
// Write a key for each tag
|
||||
await Promise.all(
|
||||
entry.meta.tags.map(async (tag) => {
|
||||
const tagKey = getTagKey(tag, key);
|
||||
|
||||
await kv.put(tagKey, jsonMetadata, {
|
||||
metadata,
|
||||
expirationTtl: secondsFromNow,
|
||||
});
|
||||
}),
|
||||
);
|
||||
await kv.put(tagKey, jsonMetadata, {
|
||||
metadata,
|
||||
expirationTtl: secondsFromNow,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
async del(keys) {
|
||||
async del(entries) {
|
||||
const kv = await getKVNamespace();
|
||||
if (!kv) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
entries.map(async ({ key }) => {
|
||||
const kvKey = getValueKey(key);
|
||||
await kv.delete(kvKey);
|
||||
}),
|
||||
);
|
||||
},
|
||||
async revalidateTags(tags) {
|
||||
const result: { keys: string[]; metas: CacheEntryMeta[] } = {
|
||||
keys: [],
|
||||
metas: [],
|
||||
};
|
||||
const result: CacheEntryMeta[] = [];
|
||||
|
||||
const kv = await getKVNamespace();
|
||||
if (!kv) {
|
||||
return result;
|
||||
return { entries: result };
|
||||
}
|
||||
|
||||
const pendingDeletions: Array<Promise<unknown>> = [];
|
||||
@@ -125,8 +116,7 @@ export const cloudflareKVCache: CacheBackend = {
|
||||
const metadata = entry.metadata;
|
||||
const key = metadata.meta.key;
|
||||
|
||||
result.metas.push(metadata.meta);
|
||||
result.keys.push(key);
|
||||
result.push(metadata.meta);
|
||||
|
||||
// Delete the tag key and the value key
|
||||
pendingDeletions.push(kv.delete(getValueKey(key)));
|
||||
@@ -147,7 +137,7 @@ export const cloudflareKVCache: CacheBackend = {
|
||||
|
||||
await Promise.all(pendingDeletions);
|
||||
|
||||
return result;
|
||||
return { entries: result };
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
-1
@@ -52,7 +52,6 @@ export function cacheResponse<Result, DefaultData = Result>(
|
||||
|
||||
return {
|
||||
ttl: defaultEntry.ttl ?? parsed.ttl,
|
||||
tags: [...(defaultEntry.tags ?? []), ...parsed.tags],
|
||||
revalidateBefore: defaultEntry.revalidateBefore,
|
||||
// @ts-ignore
|
||||
data: defaultEntry.data ?? response.data,
|
||||
|
||||
+12
-13
@@ -1,13 +1,13 @@
|
||||
import { CacheBackend, CacheEntry } from './types';
|
||||
import { CacheBackend, CacheEntry, CacheEntryLookup } from './types';
|
||||
import { NON_IMMUTABLE_LOCAL_CACHE_MAX_AGE_SECONDS, isCacheEntryImmutable } from './utils';
|
||||
import { getGlobalContext } from '../waitUntil';
|
||||
|
||||
export const memoryCache: CacheBackend = {
|
||||
name: 'memory',
|
||||
replication: 'local',
|
||||
async get(key) {
|
||||
async get(entry) {
|
||||
const memoryCache = await getMemoryCache();
|
||||
const memoryEntry = memoryCache.get(key);
|
||||
const memoryEntry = memoryCache.get(entry.key);
|
||||
|
||||
if (!memoryEntry) {
|
||||
return null;
|
||||
@@ -16,12 +16,12 @@ export const memoryCache: CacheBackend = {
|
||||
if (memoryEntry.meta.expiresAt > Date.now()) {
|
||||
return memoryEntry;
|
||||
} else {
|
||||
memoryCache.delete(key);
|
||||
memoryCache.delete(entry.key);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
async set(key, entry) {
|
||||
async set(entry) {
|
||||
const memoryCache = await getMemoryCache();
|
||||
// When the entry is immutable, we can cache it for the entire duration.
|
||||
// Else we cache it for a very short time.
|
||||
@@ -39,26 +39,25 @@ export const memoryCache: CacheBackend = {
|
||||
meta.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
memoryCache.set(key, { ...entry, meta });
|
||||
memoryCache.set(entry.meta.key, { ...entry, meta });
|
||||
},
|
||||
async del(keys) {
|
||||
async del(entries) {
|
||||
const memoryCache = await getMemoryCache();
|
||||
keys.forEach((key) => memoryCache.delete(key));
|
||||
entries.forEach((entry) => memoryCache.delete(entry.key));
|
||||
},
|
||||
async revalidateTags(tags) {
|
||||
const memoryCache = await getMemoryCache();
|
||||
const keys: string[] = [];
|
||||
const entries: CacheEntryLookup[] = [];
|
||||
|
||||
memoryCache.forEach((entry, key) => {
|
||||
if (tags.some((tag) => entry.meta.tags.includes(tag))) {
|
||||
keys.push(key);
|
||||
if (entry.meta.tag && tags.includes(entry.meta.tag)) {
|
||||
entries.push({ key, tag: entry.meta.tag });
|
||||
memoryCache.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
keys,
|
||||
metas: [],
|
||||
entries,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
import { Redis } from '@upstash/redis/cloudflare';
|
||||
|
||||
import { CacheBackend, CacheEntry, CacheEntryMeta } from './types';
|
||||
import { getCacheMaxAge } from './utils';
|
||||
import { trace } from '../tracing';
|
||||
import { filterOutNullable } from '../typescript';
|
||||
|
||||
const cacheNamespace = process.env.UPSTASH_REDIS_NAMESPACE ?? 'gitbook';
|
||||
const cacheVersion = 2;
|
||||
|
||||
export const redisCache: CacheBackend = {
|
||||
name: 'redis',
|
||||
replication: 'global',
|
||||
async get(key, options) {
|
||||
const redis = getRedis(options?.signal);
|
||||
if (!redis) {
|
||||
return null;
|
||||
}
|
||||
return trace(
|
||||
{
|
||||
operation: `redis.get`,
|
||||
name: key,
|
||||
},
|
||||
async (span) => {
|
||||
const valueKey = getCacheEntryKey(key, 'value');
|
||||
const redisEntry = await redis.get<CacheEntry>(valueKey);
|
||||
|
||||
span.setAttribute('hit', !!redisEntry);
|
||||
|
||||
if (!redisEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return redisEntry;
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async set(key, entry) {
|
||||
const redis = getRedis();
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
|
||||
return trace(
|
||||
{
|
||||
operation: `redis.set`,
|
||||
name: key,
|
||||
},
|
||||
async () => {
|
||||
const expire = getCacheMaxAge(entry.meta);
|
||||
|
||||
// Don't cache for less than 10min, as it's not worth it
|
||||
if (expire <= 10 * 60) {
|
||||
return;
|
||||
}
|
||||
|
||||
const multi = redis.multi();
|
||||
const valueKey = getCacheEntryKey(key, 'value');
|
||||
const metaKey = getCacheEntryKey(key, 'meta');
|
||||
|
||||
entry.meta.tags.forEach((tag) => {
|
||||
const redisTagKey = getCacheTagKey(tag);
|
||||
|
||||
multi.sadd(redisTagKey, key);
|
||||
|
||||
// Set am expiration on the tag to be the maximum of the expiration of all keys
|
||||
multi.expire(redisTagKey, expire, 'GT');
|
||||
multi.expire(redisTagKey, expire, 'NX');
|
||||
});
|
||||
|
||||
multi.set(valueKey, entry);
|
||||
multi.set(metaKey, entry.meta);
|
||||
multi.expire(valueKey, expire);
|
||||
multi.expire(metaKey, expire);
|
||||
|
||||
await multi.exec();
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async del(keys) {
|
||||
const redis = getRedis();
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
|
||||
const multi = redis.multi();
|
||||
keys.forEach((key) => {
|
||||
multi.del(getCacheEntryKey(key, 'value'));
|
||||
multi.del(getCacheEntryKey(key, 'meta'));
|
||||
});
|
||||
|
||||
await multi.exec();
|
||||
},
|
||||
|
||||
async revalidateTags(tags) {
|
||||
const redis = getRedis();
|
||||
if (!redis) {
|
||||
return { keys: [], metas: [] };
|
||||
}
|
||||
|
||||
const keys = new Set(
|
||||
(await Promise.all(tags.map((tag) => redis.smembers(getCacheTagKey(tag))))).flat(),
|
||||
);
|
||||
|
||||
const pipeline = redis.pipeline();
|
||||
let metas: Array<CacheEntryMeta | null> = [];
|
||||
|
||||
if (keys.size > 0) {
|
||||
// Read the meta
|
||||
metas = (
|
||||
await redis.mget<Array<CacheEntryMeta | null>>(
|
||||
// Hard limit to avoid fetching a massive list of data
|
||||
// Starts with the smallest keys.
|
||||
Array.from(keys)
|
||||
.sort((a, b) => a.length - b.length)
|
||||
.slice(0, 50)
|
||||
.map((key) => getCacheEntryKey(key, 'meta')),
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.filter(filterOutNullable);
|
||||
|
||||
// Delete all keys
|
||||
keys.forEach((key) => {
|
||||
pipeline.del(getCacheEntryKey(key, 'value'));
|
||||
});
|
||||
}
|
||||
|
||||
// And delete the tags
|
||||
tags.forEach((tag) => {
|
||||
pipeline.del(getCacheTagKey(tag));
|
||||
});
|
||||
|
||||
await pipeline.exec();
|
||||
return { keys: Array.from(keys), metas: metas.filter(filterOutNullable) };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the redis client.
|
||||
*/
|
||||
export function getRedis(signal?: AbortSignal) {
|
||||
return process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN
|
||||
? new Redis({
|
||||
url: process.env.UPSTASH_REDIS_REST_URL,
|
||||
token: process.env.UPSTASH_REDIS_REST_TOKEN,
|
||||
signal,
|
||||
automaticDeserialization: true,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key for a tag.
|
||||
*/
|
||||
function getCacheTagKey(tag: string) {
|
||||
return getRedisKey(`tags.${tag}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key for an entry.
|
||||
*/
|
||||
function getCacheEntryKey(key: string, type: 'meta' | 'value') {
|
||||
return getRedisKey(`entry.${key}.${type}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a redis key for a cache entry.
|
||||
*/
|
||||
function getRedisKey(key: string) {
|
||||
return `${cacheNamespace}.${cacheVersion}.${key}`;
|
||||
}
|
||||
+25
-37
@@ -1,83 +1,71 @@
|
||||
import { cacheBackends } from './backends';
|
||||
import { getCacheKey } from './cache';
|
||||
import { CacheEntryMeta } from './types';
|
||||
import { CacheEntryLookup } from './types';
|
||||
|
||||
interface RevalidateTagsStats {
|
||||
[key: string]: {
|
||||
/**
|
||||
* Tag associated with the key.
|
||||
*/
|
||||
tag?: string;
|
||||
|
||||
/**
|
||||
* Backends that have the key.
|
||||
*/
|
||||
[backend: string]: { set: boolean; setAt?: number; expiresAt?: number };
|
||||
backends: Record<string, { set: boolean }>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidate all values associated with tags.
|
||||
* It clears the values from the caches, but also start a background task to revalidate them.
|
||||
* Purge all cache entries associated with the given tags.
|
||||
* TODO: Implement background revalidation.
|
||||
*/
|
||||
export async function revalidateTags(tags: string[]): Promise<{
|
||||
keys: string[];
|
||||
stats: RevalidateTagsStats;
|
||||
}> {
|
||||
if (tags.length === 0) {
|
||||
return { keys: [], stats: {} };
|
||||
return { stats: {} };
|
||||
}
|
||||
|
||||
const stats: RevalidateTagsStats = {};
|
||||
|
||||
const processed = new Set<string>();
|
||||
|
||||
const keysByBackend = new Map<number, string[]>();
|
||||
const keys = new Set<string>();
|
||||
const metas: CacheEntryMeta[] = [];
|
||||
const entries = new Map<string, CacheEntryLookup>();
|
||||
|
||||
await Promise.all(
|
||||
cacheBackends.map(async (backend, backendIndex) => {
|
||||
const { keys: addedKeys, metas: addedMetas } = await backend.revalidateTags(tags);
|
||||
const { entries: addedEntries } = await backend.revalidateTags(tags);
|
||||
|
||||
console.log('revalidateTags', backend.name, addedKeys);
|
||||
addedEntries.forEach(({ key, tag }) => {
|
||||
stats[key] = stats[key] ?? {
|
||||
tag,
|
||||
backends: {},
|
||||
};
|
||||
stats[key].backends[backend.name] = { set: true };
|
||||
|
||||
addedKeys.forEach((key) => {
|
||||
stats[key] = stats[key] ?? {};
|
||||
stats[key][backend.name] = { set: true };
|
||||
|
||||
keys.add(key);
|
||||
entries.set(key, { tag, key });
|
||||
keysByBackend.set(backendIndex, [...(keysByBackend.get(backendIndex) ?? []), key]);
|
||||
});
|
||||
addedMetas.forEach((meta) => {
|
||||
const key = meta.key ?? getCacheKey(meta.cache, meta.args);
|
||||
stats[key] = stats[key] ?? {};
|
||||
stats[key][backend.name] = { set: true };
|
||||
stats[key][backend.name].setAt = meta.setAt;
|
||||
stats[key][backend.name].expiresAt = meta.expiresAt;
|
||||
|
||||
if (!processed.has(key)) {
|
||||
metas.push(meta);
|
||||
processed.add(key);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Clear the keys on the backends that didn't return them
|
||||
await Promise.all(
|
||||
cacheBackends.map(async (backend, backendIndex) => {
|
||||
const unclearedKeys = Array.from(keys).filter(
|
||||
(key) => !keysByBackend.get(backendIndex)?.includes(key),
|
||||
const unclearedEntries = Array.from(entries.values()).filter(
|
||||
(entry) => !keysByBackend.get(backendIndex)?.includes(entry.key),
|
||||
);
|
||||
|
||||
if (unclearedKeys.length > 0) {
|
||||
unclearedKeys.forEach((key) => {
|
||||
stats[key][backend.name] = { set: false };
|
||||
if (unclearedEntries.length > 0) {
|
||||
unclearedEntries.forEach((entry) => {
|
||||
stats[entry.key].backends[backend.name] = { set: false };
|
||||
});
|
||||
|
||||
await backend.del(unclearedKeys);
|
||||
await backend.del(unclearedEntries);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
keys: Array.from(keys.keys()),
|
||||
stats,
|
||||
};
|
||||
}
|
||||
|
||||
+8
-11
@@ -1,6 +1,9 @@
|
||||
export interface CacheEntryMeta {
|
||||
export interface CacheEntryLookup {
|
||||
key: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export interface CacheEntryMeta extends CacheEntryLookup {
|
||||
/**
|
||||
* Timestamp when the entry was created.
|
||||
*/
|
||||
@@ -21,12 +24,6 @@ export interface CacheEntryMeta {
|
||||
*/
|
||||
revalidatesAt?: number;
|
||||
|
||||
/**
|
||||
* Tags associated with the entry, used for revalidation.
|
||||
* If no tags is present, the entry is considered immutable.
|
||||
*/
|
||||
tags: string[];
|
||||
|
||||
/**
|
||||
* Arguments that were passed to the function.
|
||||
*/
|
||||
@@ -51,21 +48,21 @@ export interface CacheBackend {
|
||||
/**
|
||||
* Get a value from the cache.
|
||||
*/
|
||||
get(key: string, options?: { signal?: AbortSignal }): Promise<CacheEntry | null>;
|
||||
get(entry: CacheEntryLookup, options?: { signal?: AbortSignal }): Promise<CacheEntry | null>;
|
||||
|
||||
/**
|
||||
* Set a value in the cache.
|
||||
*/
|
||||
set(key: string, entry: CacheEntry): Promise<void>;
|
||||
set(entry: CacheEntry): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a value from the cache.
|
||||
*/
|
||||
del(keys: string[]): Promise<void>;
|
||||
del(keys: CacheEntryLookup[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Revalidate all keys associated with tags.
|
||||
* It should return the meta of all entries that were revalidated.
|
||||
*/
|
||||
revalidateTags(tags: string[]): Promise<{ keys: string[]; metas: CacheEntryMeta[] }>;
|
||||
revalidateTags(tags: string[]): Promise<{ entries: Array<CacheEntryLookup | CacheEntryMeta> }>;
|
||||
}
|
||||
|
||||
+1
-1
@@ -23,5 +23,5 @@ export function getCacheMaxAge(meta: CacheEntryMeta, min?: number, max?: number)
|
||||
* Return true if a cache entry can be considered immutable.
|
||||
*/
|
||||
export function isCacheEntryImmutable(meta: CacheEntryMeta): boolean {
|
||||
return !meta.tags || meta.tags.length === 0;
|
||||
return !meta.tag;
|
||||
}
|
||||
|
||||
@@ -48,27 +48,30 @@ export async function fetchOpenAPIBlock(
|
||||
}
|
||||
|
||||
const fetcher: OpenAPIFetcher = {
|
||||
fetch: cache('openapi.fetch', async (url: string, options: CacheFunctionOptions) => {
|
||||
// Wrap the raw string to prevent invalid URLs from being passed to fetch.
|
||||
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
|
||||
// https://github.com/cloudflare/workerd/issues/1957
|
||||
const response = await fetch(new URL(url), {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
fetch: cache({
|
||||
name: 'openapi.fetch',
|
||||
get: async (url: string, options: CacheFunctionOptions) => {
|
||||
// Wrap the raw string to prevent invalid URLs from being passed to fetch.
|
||||
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
|
||||
// https://github.com/cloudflare/workerd/issues/1957
|
||||
const response = await fetch(new URL(url), {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const data = await parseOpenAPIV3(url, text);
|
||||
return {
|
||||
...parseCacheResponse(response),
|
||||
data,
|
||||
};
|
||||
const text = await response.text();
|
||||
const data = await parseOpenAPIV3(url, text);
|
||||
return {
|
||||
...parseCacheResponse(response),
|
||||
data,
|
||||
};
|
||||
},
|
||||
}),
|
||||
parseMarkdown,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
import { Collection, ContentVisibility, Site, SiteVisibility, Space } from '@gitbook/api';
|
||||
import {
|
||||
Collection,
|
||||
ContentVisibility,
|
||||
RevisionPageDocument,
|
||||
RevisionPageGroup,
|
||||
Site,
|
||||
SiteVisibility,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
/**
|
||||
* Return true if a page is indexable in search.
|
||||
*/
|
||||
export function isPageIndexable(
|
||||
ancestors: Array<RevisionPageDocument | RevisionPageGroup>,
|
||||
page: RevisionPageDocument | RevisionPageGroup,
|
||||
): boolean {
|
||||
// @ts-ignore - noIndex and noRobotsIndex are not in the type
|
||||
// until we fix the deprecated APIs
|
||||
return (
|
||||
// @ts-ignore
|
||||
!page.noIndex &&
|
||||
// @ts-ignore
|
||||
!page.noRobotsIndex &&
|
||||
// @ts-ignore
|
||||
ancestors.every((ancestor) => !ancestor.noIndex && !ancestor.noRobotsIndex)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if a space should be indexed by search engines.
|
||||
*/
|
||||
export function shouldIndexSpace({
|
||||
export function isSpaceIndexable({
|
||||
space,
|
||||
parent,
|
||||
}: {
|
||||
|
||||
@@ -17,7 +17,8 @@ export function getContentTitle(
|
||||
// When we are rendering a site, always give priority to the customization title first
|
||||
// and then fallback to the site title
|
||||
if (parent?.object === 'site') {
|
||||
return customization.title ?? parent.title ?? space.title;
|
||||
// the parent title for a site is already overridden by the customized title in the fetch call
|
||||
return parent.title ?? space.title;
|
||||
}
|
||||
|
||||
// Otherwise the legacy behavior is not changed to avoid regressions
|
||||
|
||||
@@ -216,6 +216,15 @@ export async function middleware(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// For tests, we make it possible to enable search indexation
|
||||
// using a query parameter.
|
||||
const xGitBookSearchIndexation =
|
||||
headers.get('x-gitbook-search-indexation') ??
|
||||
url.searchParams.has('x-gitbook-search-indexation');
|
||||
if (xGitBookSearchIndexation) {
|
||||
headers.set('x-gitbook-search-indexation', 'true');
|
||||
}
|
||||
|
||||
if (resolved.revision) {
|
||||
headers.set('x-gitbook-content-revision', resolved.revision);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,6 @@
|
||||
"bun-types" // add Bun global
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": ["next-env.d.ts", "cf-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules", "packages/react-openapi", "packages/react-math"]
|
||||
}
|
||||
|
||||
@@ -60,12 +60,19 @@ async function main() {
|
||||
|
||||
async function writeDataFile(name, content) {
|
||||
const dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
|
||||
const srcData = path.resolve(dirname, '../src/data');
|
||||
const distData = path.resolve(dirname, '../dist/data');
|
||||
|
||||
// Ensure the directories exists
|
||||
await Promise.all([
|
||||
fs.writeFile(path.resolve(dirname, `../src/data/${name}.json`), content),
|
||||
// Write to dist folder if it exists
|
||||
existsSync(path.resolve(dirname, '../dist/'))
|
||||
? fs.writeFile(path.resolve(dirname, `../dist/data/${name}.json`), content)
|
||||
: null,
|
||||
fs.mkdir(srcData, { recursive: true }),
|
||||
fs.mkdir(distData, { recursive: true }),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
fs.writeFile(path.resolve(srcData, `${name}.json`), content),
|
||||
fs.writeFile(path.resolve(distData, `${name}.json`), content),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"generate": "node ./bin/gen-list.js",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w"
|
||||
"dev": "tsc -w",
|
||||
"clean": "rm -rf ./dist && rm -rf ./src/data"
|
||||
},
|
||||
"bin": {
|
||||
"gitbook-icons": "./bin/gitbook-icons.js"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Files in this folder are generated in postinstall script.
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"classnames": "^2.5.1",
|
||||
"@gitbook/api": "^0.58.0",
|
||||
"@gitbook/api": "0.60.0",
|
||||
"assert-never": "^1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -22,7 +22,8 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w"
|
||||
"dev": "tsc -w",
|
||||
"clean": "rm -rf ./dist"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w"
|
||||
"dev": "tsc -w",
|
||||
"clean": "rm -rf ./dist"
|
||||
},
|
||||
"bin": {
|
||||
"gitbook-math": "./bin/gitbook-math.js"
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
"classnames": "^2.5.1",
|
||||
"flatted": "^3.2.9",
|
||||
"openapi-types": "^12.1.3",
|
||||
"yaml": "1.10.2",
|
||||
"swagger2openapi": "^7.0.8"
|
||||
"swagger2openapi": "^7.0.8",
|
||||
"yaml": "1.10.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/swagger2openapi": "^7.0.4",
|
||||
@@ -24,13 +24,15 @@
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
"react": "*",
|
||||
"recoil": "^0.7.7"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"unit": "bun test",
|
||||
"dev": "tsc -w"
|
||||
"dev": "tsc -w",
|
||||
"clean": "rm -rf ./dist"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user