Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/composite/deploy-cloudflare/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ runs:
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build worker
Expand Down
1 change: 1 addition & 0 deletions .github/composite/deploy-vercel/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ runs:
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build Project Artifacts
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,15 @@ bun install
4. Start your local development server.

```
bun dev
bun dev:v2
```

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
- http://localhost:3000/url/docs.gitbook.com
- http://localhost:3000/url/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.

Expand Down
21 changes: 11 additions & 10 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
"@gitbook/api": "*",
"@gitbook/cache-tags": "workspace:*",
"@sindresorhus/fnv1a": "^3.1.0",
"assert-never": "^1.2.1",
"jwt-decode": "^4.0.0",
"next": "canary",
"react": "^19.0.0",
Expand Down Expand Up @@ -4017,7 +4018,7 @@

"gaxios/node-fetch": ["[email protected]", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],

"gitbook-v2/next": ["[email protected]", "", { "dependencies": { "@next/env": "15.3.1-canary.1", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.1-canary.1", "@next/swc-darwin-x64": "15.3.1-canary.1", "@next/swc-linux-arm64-gnu": "15.3.1-canary.1", "@next/swc-linux-arm64-musl": "15.3.1-canary.1", "@next/swc-linux-x64-gnu": "15.3.1-canary.1", "@next/swc-linux-x64-musl": "15.3.1-canary.1", "@next/swc-win32-arm64-msvc": "15.3.1-canary.1", "@next/swc-win32-x64-msvc": "15.3.1-canary.1", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-J6xERAGPJVvHq61masP56+cQYzF3Ey72QiU0dz1aDPATMyX+Xs8lANqEbbX0Qj1mRLPl6D9YB+kAP1nZwV1K7Q=="],
"gitbook-v2/next": ["[email protected]", "", { "dependencies": { "@next/env": "15.3.1-canary.4", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.1-canary.4", "@next/swc-darwin-x64": "15.3.1-canary.4", "@next/swc-linux-arm64-gnu": "15.3.1-canary.4", "@next/swc-linux-arm64-musl": "15.3.1-canary.4", "@next/swc-linux-x64-gnu": "15.3.1-canary.4", "@next/swc-linux-x64-musl": "15.3.1-canary.4", "@next/swc-win32-arm64-msvc": "15.3.1-canary.4", "@next/swc-win32-x64-msvc": "15.3.1-canary.4", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-bVUdzmbfd3gEu30Riin463JNwTRbcOY/DlCY/+WhgwUR6Sfu1uJeyl3kGAjw58I+ZoNX2omJVpTd9AMluZ26qA=="],

"global-dirs/ini": ["[email protected]", "", {}, "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ=="],

Expand Down Expand Up @@ -4885,23 +4886,23 @@

"gaxios/https-proxy-agent/debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],

"gitbook-v2/next/@next/env": ["@next/[email protected].1", "", {}, "sha512-sPJsmQ9JIVBY7rOVw7Q2STByyQoS9707ZoZG16ApV72t22Vj89m015MWaDcFWbew4qCN6PCQYnFdQB3Nx4d3ww=="],
"gitbook-v2/next/@next/env": ["@next/[email protected].4", "", {}, "sha512-Bw464vR3fVUrhHQOh0o0ilXQ6wg6OvsNn3afUTjm1a0n0JmJJ+n9M1041xmBHSBGWUfTe74HepSmy79sF8EDlA=="],

"gitbook-v2/next/@next/swc-darwin-arm64": ["@next/[email protected].1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ALMi3dK7zguKMGweSzJOkderPxGV7wERDazXxCSxNS93K0D45GYRvNWxFi36PonOt8C/SF6l9JN3oryWFWxErA=="],
"gitbook-v2/next/@next/swc-darwin-arm64": ["@next/[email protected].4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZR497D00W62Tf566uakD+VvA/2Cmagix3suVlB7TxLSPxXFAU5DeGQvaT9jkP+x1lEKggcZ+chI1CB9CcjvlTQ=="],

"gitbook-v2/next/@next/swc-darwin-x64": ["@next/[email protected].1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dxuvwbyl5Akgu4AC73YUgwCnfxmucEdlqnzLMLjMp4f2dlfs2H3xmCQbI/p/jfpK3MSr+NtyqfRy8hxKjfRBLw=="],
"gitbook-v2/next/@next/swc-darwin-x64": ["@next/[email protected].4", "", { "os": "darwin", "cpu": "x64" }, "sha512-dX0X7NG6goshCgbKsSzn6wVzgkRlUqRXMjO9E46B6HqcV/L8l15rMVhWr8zBzDt/7gEmAylnlnML8Zqv9BiF1Q=="],

"gitbook-v2/next/@next/swc-linux-arm64-gnu": ["@next/[email protected].1", "", { "os": "linux", "cpu": "arm64" }, "sha512-YTKsRz7oVIKNrpkbpTxDB9SuYJumELVncyG/f82SGGFuql5aE5jmQETNCATFYmAQvO2a3c02HF2lF1s7hEOg0A=="],
"gitbook-v2/next/@next/swc-linux-arm64-gnu": ["@next/[email protected].4", "", { "os": "linux", "cpu": "arm64" }, "sha512-ehE8KbvyBa7grVzC+n+L9CHbainjg362coYObhak3Jktnvb0uPf/m2kqgfN3UBzGSAczvcAuEbsLulFifAnFrw=="],

"gitbook-v2/next/@next/swc-linux-arm64-musl": ["@next/[email protected].1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yIcd6YBSFz/lpJFSPpLxDpg7Tfyf/NxFeBBbYfmcjs2AZ0/+NUyAwFUfI1FKY5Lwym+bEfrEvhVWzmPZsYNQXA=="],
"gitbook-v2/next/@next/swc-linux-arm64-musl": ["@next/[email protected].4", "", { "os": "linux", "cpu": "arm64" }, "sha512-5NBstNVZeRP0OeRQGZqQGmrUdMVsMS556BekDeFqgwMqN/Ibq/EYwMMn1dkP26Dln9qIOj167Mde9CC2c3SQUg=="],

"gitbook-v2/next/@next/swc-linux-x64-gnu": ["@next/[email protected].1", "", { "os": "linux", "cpu": "x64" }, "sha512-2FKSD+vlBfyqpWJsOsBpsM+OB2iDybpPkONToGgFaurdbxue88lzrUuQg8FX/BxS5yjkP15mYubnTdBIxKa+yg=="],
"gitbook-v2/next/@next/swc-linux-x64-gnu": ["@next/[email protected].4", "", { "os": "linux", "cpu": "x64" }, "sha512-6CVq5yFWj2uZ3VgSl0OcCVVfH+EZhOqDocvCeXRaEvpVWq0s9zXPA6GgZghNY7Z5YSZXkB6z7Qi3xG+hzNLQ0A=="],

"gitbook-v2/next/@next/swc-linux-x64-musl": ["@next/[email protected].1", "", { "os": "linux", "cpu": "x64" }, "sha512-MpQLsXsEenGCCWZXMcCVmn7RqYTUNjS3gjPJL/8ILJhf2aKLzo1Z3XMepty9zzupd/E8bJQbegzOxxBYgriiKA=="],
"gitbook-v2/next/@next/swc-linux-x64-musl": ["@next/[email protected].4", "", { "os": "linux", "cpu": "x64" }, "sha512-3AiB/lUOa8QMsUM4KW0IYuMqvvMLQyANb13pGKihemtYUEjPS4/Jf5/vkz0qWF0AOP/1DNECdKpCIenU2C2pIA=="],

"gitbook-v2/next/@next/swc-win32-arm64-msvc": ["@next/[email protected].1", "", { "os": "win32", "cpu": "arm64" }, "sha512-NWYm76qsVKX5k/7MBiQy/fNK8tiEVvaAANC3x7VocLSphyozARoXpc15q3m2rexc5EY5/MwjQjrP81Awz5ldhQ=="],
"gitbook-v2/next/@next/swc-win32-arm64-msvc": ["@next/[email protected].4", "", { "os": "win32", "cpu": "arm64" }, "sha512-WACNwz1q2DtYqZXj+IjGME3D6XxRkkyuynMyC6d4tH5IahnxiwkJtfaRE1DwWWbVUPQ6TtevSEgCOh3eFk5yFQ=="],

"gitbook-v2/next/@next/swc-win32-x64-msvc": ["@next/[email protected].1", "", { "os": "win32", "cpu": "x64" }, "sha512-6piB1mQPtZpLuhgNoJuvpqIbqY9pOzoa3sG24hzK1H9ct9I4m3XQM2as3wyoe7PVJaYk6IZ/rfVU+WDfRhRxKQ=="],
"gitbook-v2/next/@next/swc-win32-x64-msvc": ["@next/[email protected].4", "", { "os": "win32", "cpu": "x64" }, "sha512-qbmYHxGD3MaI3OJ0NLRcBhfDoSnkvqqrZNmPIuPSlkOUlj+eIuyGtWMRocaYCGTEvQstlspymIsVzSfiyI+6ng=="],

"gitbook-v2/next/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],

Expand Down
1 change: 1 addition & 0 deletions packages/gitbook-v2/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const nextConfig = {
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,

Expand Down
3 changes: 2 additions & 1 deletion packages/gitbook-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"server-only": "^0.0.1",
"warn-once": "^0.1.1",
"rison": "^0.1.1",
"jwt-decode": "^4.0.0"
"jwt-decode": "^4.0.0",
"assert-never": "^1.2.1"
},
"devDependencies": {
"gitbook": "*",
Expand Down
18 changes: 18 additions & 0 deletions packages/gitbook-v2/src/lib/env/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ export const GITBOOK_IMAGE_RESIZE_URL = process.env.GITBOOK_IMAGE_RESIZE_URL ??
export const GITBOOK_IMAGE_RESIZE_SIGNING_KEY =
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY ?? null;

/**
* Mode used for resizing images.
*/
export const GITBOOK_IMAGE_RESIZE_MODE = enforceEnum(
'GITBOOK_IMAGE_RESIZE_MODE',
process.env.GITBOOK_IMAGE_RESIZE_MODE || 'cdn-cgi',
['cdn-cgi', 'cf-fetch']
);

/**
* Endpoint where icons are served.
*/
Expand All @@ -100,3 +109,12 @@ export const GITBOOK_ICONS_TOKEN = process.env.GITBOOK_ICONS_TOKEN;
* Secret used to validate requests from the GitBook app.
*/
export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null;

function enforceEnum<T extends string>(key: string, value: string, enumValues: T[]): T {
if (!enumValues.includes(value as T)) {
throw new Error(
`Invalid value for ${key}: "${value}", expected one of: ${enumValues.join(', ')}`
);
}
return value as T;
}
27 changes: 27 additions & 0 deletions packages/gitbook-v2/src/lib/images/checkIsSizableImageURL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { checkIsHttpURL } from '@/lib/urls';

/**
* Check if an image URL is resizable.
* Skip it for non-http(s) URLs (data, etc).
* Skip it for SVGs.
* Skip it for GitBook images (to avoid recursion).
*/
export function checkIsSizableImageURL(input: string): boolean {
if (!URL.canParse(input)) {
return false;
}

if (input.includes('/~gitbook/image')) {
return false;
}

const parsed = new URL(input);
if (parsed.pathname.endsWith('.svg') || parsed.pathname.endsWith('.avif')) {
return false;
}
if (!checkIsHttpURL(parsed)) {
return false;
}

return true;
}
145 changes: 2 additions & 143 deletions packages/gitbook-v2/src/lib/images/createImageResizer.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,11 @@
import 'server-only';

import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
import type { GitBookLinker } from '../links';
import { checkIsSizableImageURL } from './checkIsSizableImageURL';
import { getImageSize } from './resizer';
import { type SignatureVersion, generateImageSignature } from './signatures';
import type { ImageResizer } from './types';

interface CloudflareImageJsonFormat {
width: number;
height: number;
original: {
file_size: number;
width: number;
height: number;
format: string;
};
}

/**
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
*/
export interface CloudflareImageOptions {
format?: 'webp' | 'avif' | 'json' | 'jpeg';
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
width?: number;
height?: number;
dpr?: number;
anim?: boolean;
quality?: number;
}

/**
* Create an image resizer for a rendering context.
*/
Expand Down Expand Up @@ -106,124 +83,6 @@ export function createNoopImageResizer(): ImageResizer {
};
}

/**
* Check if a URL is an HTTP URL.
*/
export function checkIsHttpURL(input: string | URL): boolean {
if (!URL.canParse(input)) {
return false;
}
const parsed = new URL(input);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
}

/**
* Check if an image URL is resizable.
* Skip it for non-http(s) URLs (data, etc).
* Skip it for SVGs.
* Skip it for GitBook images (to avoid recursion).
*/
export function checkIsSizableImageURL(input: string): boolean {
if (!URL.canParse(input)) {
return false;
}

if (input.includes('/~gitbook/image')) {
return false;
}

const parsed = new URL(input);
if (parsed.pathname.endsWith('.svg') || parsed.pathname.endsWith('.avif')) {
return false;
}
if (!checkIsHttpURL(parsed)) {
return false;
}

return true;
}

/**
* Get the size of an image.
*/
export async function getImageSize(
input: string,
defaultSize: Partial<CloudflareImageOptions> = {}
): Promise<{ width: number; height: number } | null> {
if (!checkIsSizableImageURL(input)) {
return null;
}

try {
const response = await resizeImage(input, {
// Abort the request after 2 seconds to avoid blocking rendering for too long
signal: AbortSignal.timeout(2000),
// Measure size and resize it to the most common size
// to optimize caching
...defaultSize,
format: 'json',
anim: false,
});

const json = (await response.json()) as CloudflareImageJsonFormat;
return {
width: json.original.width,
height: json.original.height,
};
} catch (_error) {
return null;
}
}

/**
* Execute a Cloudflare Image Resize operation on an image.
*/
export async function resizeImage(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const { signal, ...resizeOptions } = options;

const parsed = new URL(input);
if (parsed.protocol === 'data:') {
throw new Error('Cannot resize data: URLs');
}

if (parsed.hostname === 'localhost') {
throw new Error('Cannot resize localhost URLs');
}

// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
// we need to use the Cloudflare Image Resize API directly.
if (!GITBOOK_IMAGE_RESIZE_URL) {
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set');
}

return await fetch(
`${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
resizeOptions
)}/${encodeURIComponent(input)}`,
{
headers: {
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
Accept:
resizeOptions.format === 'json'
? 'application/json'
: `image/${resizeOptions.format || 'jpeg'}`,
},
signal,
}
);
}

function stringifyOptions(options: CloudflareImageOptions): string {
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
return `${rest}${rest ? ',' : ''}${key}=${value}`;
}, '');
}

/**
* Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost.
* We protect against it by converting to a special token, and then parsing
Expand Down
2 changes: 2 additions & 0 deletions packages/gitbook-v2/src/lib/images/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export * from './createImageResizer';
export * from './signatures';
export * from './utils';
export * from './getImageResizingContextId';
export * from './resizer';
export * from './checkIsSizableImageURL';
45 changes: 45 additions & 0 deletions packages/gitbook-v2/src/lib/images/resizer/cdn-cgi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { GITBOOK_IMAGE_RESIZE_URL } from '@v2/lib/env';
import type { CloudflareImageOptions } from './types';

/**
* Resize an image by doing a request to a /cdn/cgi/ endpoint.
* https://developers.cloudflare.com/images/transform-images/transform-via-url/
*/
export async function resizeImageWithCDNCgi(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const { signal, ...resizeOptions } = options;

// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
// we need to use the Cloudflare Image Resize API directly.
if (!GITBOOK_IMAGE_RESIZE_URL) {
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set for cdn-cgi image resize mode');
}

const resizeURL = `${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
resizeOptions
)}/${encodeURIComponent(input)}`;

// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
console.log(`resize image using cdn-cgi: ${resizeURL}`);

return await fetch(resizeURL, {
headers: {
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
Accept:
resizeOptions.format === 'json'
? 'application/json'
: `image/${resizeOptions.format || 'jpeg'}`,
},
signal,
});
}

function stringifyOptions(options: CloudflareImageOptions): string {
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
return `${rest}${rest ? ',' : ''}${key}=${value}`;
}, '');
}
Loading
Loading