Compare commits

...

4 Commits

Author SHA1 Message Date
Nicolas Dorseuil b2ed5c8c80 initial implementation of a custom tag cache 2025-06-09 16:05:38 +02:00
Brett Jephson c0ee60ec13 RND-6843: columns block (#3257)
Co-authored-by: Steven Hall <steven@gitbook.io>
2025-06-09 11:37:56 +01:00
conico974 f58b904ba6 Encode customization header (#3289)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-09 11:37:29 +02:00
conico974 d5992f1a5c Bump OpenNext to latest (#3288)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-09 10:40:44 +02:00
13 changed files with 499 additions and 56 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook-v2": patch
"gitbook": patch
---
Adds Columns layout block to GBO
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook-v2": patch
"gitbook": patch
---
encode customization header
+5 -3
View File
@@ -152,7 +152,7 @@
"dependencies": {
"@gitbook/api": "^0.119.0",
"@gitbook/cache-tags": "workspace:*",
"@opennextjs/cloudflare": "1.1.0",
"@opennextjs/cloudflare": "1.2.1",
"@sindresorhus/fnv1a": "^3.1.0",
"assert-never": "^1.2.1",
"jwt-decode": "^4.0.0",
@@ -798,9 +798,9 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@opennextjs/aws": ["@opennextjs/aws@3.6.4", "", { "dependencies": { "@ast-grep/napi": "^0.35.0", "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "esbuild": "0.25.4", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0", "yaml": "^2.7.0" }, "bin": { "open-next": "dist/index.js" } }, "sha512-/bn9N/6dVu9+sC7AptaGJylKUzyDDgqe3yKfvUxXOpy7whIq/+3Bw+q2/bilyQg6FcskbCWUt9nLvNf1hAVmfA=="],
"@opennextjs/aws": ["@opennextjs/aws@3.6.5", "", { "dependencies": { "@ast-grep/napi": "^0.35.0", "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0", "yaml": "^2.7.0" }, "bin": { "open-next": "dist/index.js" } }, "sha512-wni+CWlRCyWfhNfekQBBPPkrDDnaGdZLN9hMybKI0wKOKTO+zhPOqR65Eh3V0pzWAi84Sureb5mdMuLwCxAAcw=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.1.0", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.6.4", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6" }, "peerDependencies": { "wrangler": "^4.14.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-8DOzpLiewivA1pwRNGVPDXbrF79bzJiHscY+M1tekwvKqEqM26CRYafhrA5IDCeyteNaL9AZD6X4DLuCn/eeYQ=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.2.1", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.6.5", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6" }, "peerDependencies": { "wrangler": "^4.19.1" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-cOco+nHwlo/PLB1bThF8IIvaS8PgAT9MEI5ZttFO/qt6spgvr2lUaPkpjgSIQmI3sBIEG2cLUykvQ2nbbZEcVw=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -3666,6 +3666,8 @@
"@node-minify/core/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
"@opennextjs/aws/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
"@radix-ui/react-collection/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="],
@@ -22,6 +22,12 @@
"bucket_name": "gitbook-open-v2-cache-preview"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-preview"
}
],
"durable_objects": {
"bindings": [
{
@@ -61,6 +67,12 @@
"service": "gitbook-x-staging-tail"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-staging"
}
],
"durable_objects": {
"bindings": [
{
@@ -100,6 +112,12 @@
"service": "gitbook-x-prod-tail"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-production"
}
],
"durable_objects": {
"bindings": [
{
@@ -1,46 +1,101 @@
import { trace } from '@/lib/tracing';
import type { DurableObjectLocationHint } from '@cloudflare/workers-types';
import { generateShardId } from '@opennextjs/aws/core/routing/queue.js';
import type { NextModeTagCache } from '@opennextjs/aws/types/overrides.js';
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
const originalTagCache = doShardedTagCache({
baseShardSize: 12,
regionalCache: true,
// We can probably increase this value even further
regionalCacheTtlSec: 60,
shardReplication: {
numberOfSoftReplicas: 2,
numberOfHardReplicas: 1,
regionalReplication: {
defaultRegion: 'enam',
},
},
});
export const DEFAULT_REGION = 'enam';
//TODO: We may not need all these regions.
export const AVAILABLE_REGIONS = [
'enam',
'weur',
'apac',
'sam',
'afr',
'oc',
] as DurableObjectLocationHint[];
export default {
name: 'GitbookTagCache',
mode: 'nextMode',
getLastRevalidated: async (tags: string[]) => {
const tagsToCheck = tags.filter(softTagFilter);
if (tagsToCheck.length === 0) {
// If we reach here, it probably means that there is an issue that we'll need to address.
console.warn(
'getLastRevalidated - No valid tags to check for last revalidation, original tags:',
tags
);
return 0; // If no tags to check, return 0
/**
* - `change-request` are only used by preview urls. One DO per region and space
* - `internal` are used for the release tag and integrations which does not depend on the user
* - `global` are used for url and site tag
* - `space` are only used the space tag (i.e. when a CR is merged)
* - `organization` are used for translation and openAPI. One DO per region and organization
*/
type TagType = 'change-request' | 'internal' | 'global' | 'space' | 'organization';
interface DOIdOptions {
baseShardId: string;
numberOfReplicas: number;
shardType: TagType;
replicaId?: number;
region?: DurableObjectLocationHint;
tag: string;
}
interface CacheTagKeyOptions {
doId: DOId;
tags: string[];
}
export class DOId {
options;
shardId;
replicaId;
region;
constructor(options: DOIdOptions) {
this.options = options;
const { baseShardId, shardType, numberOfReplicas, replicaId, region } = options;
this.shardId = `tag-${shardType};${baseShardId}`;
this.replicaId = replicaId ?? this.generateRandomNumberBetween(1, numberOfReplicas);
this.region = region;
}
generateRandomNumberBetween(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
get key() {
// For those 2 type of tags, we likely don't need any kind of replication or even sharding
// We may reconsider it for `change-request` at some point
if (this.options.shardType === 'organization') {
const organization = this.options.tag.split(':')[1];
return `${this.shardId};region-${this.region};org:${organization}`;
}
return trace(
{
operation: 'gitbookTagCacheGetLastRevalidated',
name: tagsToCheck.join(', '),
},
async () => {
return await originalTagCache.getLastRevalidated(tagsToCheck);
}
);
},
hasBeenRevalidated: async (tags: string[], lastModified?: number) => {
if (this.options.shardType === 'change-request') {
const space = this.options.tag.split(':')[1];
return `${this.shardId};region-${this.region};space:${space}`;
}
return `${this.shardId};replica-${this.replicaId}${this.region ? `;region-${this.region}` : ''}`;
}
}
class GitbookTagCache implements NextModeTagCache {
localCache: Cache | undefined;
maxWriteRetries = 3;
constructor(
private config: {
numberOfShards: Record<TagType, number>;
numberOfReplicas: Record<TagType, number>;
ttl: Record<TagType, number>;
}
) {}
name = 'GitbookTagCache';
mode = 'nextMode' as const;
// This one is only used for soft tags, no need to do anything for us
async getLastRevalidated(_tags: string[]) {
return 0;
}
getDurableObjectStub(doId: DOId) {
const durableObject = getCloudflareContext().env.NEXT_TAG_CACHE_DO_SHARDED;
if (!durableObject) throw new Error('No durable object binding for cache revalidation');
const id = durableObject.idFromName(doId.key);
return durableObject.get(id, { locationHint: doId.region });
}
async hasBeenRevalidated(tags: string[], lastModified?: number) {
const tagsToCheck = tags.filter(softTagFilter);
if (tagsToCheck.length === 0) {
// If we reach here, it probably means that there is an issue that we'll need to address.
@@ -56,12 +111,38 @@ export default {
name: tagsToCheck.join(', '),
},
async () => {
const result = await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
return result;
const shardedTagGroups = this.groupTagsByDO({ tags });
const shardedTagRevalidationOutcomes = await Promise.all(
shardedTagGroups.map(async ({ doId, tags }) => {
const cachedValue = await this.getFromRegionalCache({
doId,
tags,
});
if (cachedValue) {
return (await cachedValue.text()) === 'true';
}
const stub = this.getDurableObjectStub(doId);
const _hasBeenRevalidated = await stub.hasBeenRevalidated(
tags,
lastModified
);
//TODO: Do we want to cache the result if it has been revalidated ?
// If we do so, we risk causing cache MISS even though it has been revalidated elsewhere
// On the other hand revalidating a tag that is used in a lot of places will cause a lot of requests
if (!_hasBeenRevalidated) {
getCloudflareContext().ctx.waitUntil(
this.putToRegionalCache({ doId, tags }, _hasBeenRevalidated)
);
}
return _hasBeenRevalidated;
})
);
return shardedTagRevalidationOutcomes.some((result) => result);
}
);
},
writeTags: async (tags: string[]) => {
}
async writeTags(tags: string[]) {
return trace(
{
operation: 'gitbookTagCacheWriteTags',
@@ -74,8 +155,250 @@ export default {
return; // If no tags to write, exit early
}
// Write only the filtered tags
await originalTagCache.writeTags(tagsToWrite);
const shardedTagGroups = this.groupTagsByDO({
tags: tagsToWrite,
});
// We want to use the same revalidation time for all tags
const currentTime = Date.now();
await Promise.all(
shardedTagGroups.map(async ({ doId, tags }) => {
await this.performWriteTagsWithRetry(doId, tags, currentTime);
})
);
}
);
}
async performWriteTagsWithRetry(
doId: DOId,
tags: string[],
lastModified: number,
retryNumber = 0
) {
try {
const stub = this.getDurableObjectStub(doId);
await stub.writeTags(tags, lastModified);
// Depending on the shards and the tags, deleting from the regional cache will not work for every tag
// We also need to delete both cache
await Promise.all([this.deleteRegionalCache({ doId, tags })]);
} catch (e) {
if (retryNumber >= this.maxWriteRetries) {
console.error('Error while writing tags, too many retries', e);
// Do we want to throw an error here ?
await getCloudflareContext().env.NEXT_TAG_CACHE_DO_SHARDED_DLQ?.send({
failingShardId: doId.key,
failingTags: tags,
lastModified,
});
return;
}
await this.performWriteTagsWithRetry(doId, tags, lastModified, retryNumber + 1);
}
}
/**
* Same tags are guaranteed to be in the same shard
* @param tags
* @returns An array of DO ids and tags
*/
groupTagsByDO({ tags }: { tags: string[] }) {
const crTags = this.generateDOIdArray({ tags, shardType: 'change-request' });
const spaceTags = this.generateDOIdArray({ tags, shardType: 'space' });
const internalTags = this.generateDOIdArray({ tags, shardType: 'internal' });
const globalTags = this.generateDOIdArray({ tags, shardType: 'global' });
const orgTags = this.generateDOIdArray({ tags, shardType: 'organization' });
const tagIdCollection = [
...crTags,
...spaceTags,
...internalTags,
...globalTags,
...orgTags,
];
// We then group the tags by DO id
const tagsByDOId = new Map<string, { doId: DOId; tags: string[] }>();
for (const { doId, tag } of tagIdCollection) {
const doIdString = doId.key;
const tagsArray = tagsByDOId.get(doIdString)?.tags ?? [];
tagsArray.push(tag);
tagsByDOId.set(doIdString, {
// We override the doId here, but it should be the same for all tags
doId,
tags: tagsArray,
});
}
const result = Array.from(tagsByDOId.values());
return result;
}
/**
* Generates a list of DO ids for the shards and replicas
* @param tags The tags to generate shards for
* @param shardType The type of the shard (change-request, internal, global, space)
* @returns An array of TagCacheDOId and tag
*/
generateDOIdArray({
tags,
shardType,
}: {
tags: string[];
shardType: TagType;
}) {
const numReplicas = this.config.numberOfReplicas[shardType];
const replicaIndexes = Array.from({ length: numReplicas }, (_, i) => i + 1);
const regionalReplicas = replicaIndexes.flatMap((replicaId) => {
return tags
.filter((tag) => this.getTagType(tag) === shardType)
.map((tag) => {
return {
doId: new DOId({
baseShardId: generateShardId(
tag,
this.config.numberOfShards[shardType],
'shard'
),
numberOfReplicas: numReplicas,
shardType,
replicaId,
tag,
}),
tag,
};
});
});
// If we have regional replication enabled, we need to further duplicate the shards in all the regions
const regionalReplicasInAllRegions = regionalReplicas.flatMap(({ doId, tag }) => {
return AVAILABLE_REGIONS.map((region) => {
return {
doId: new DOId({
baseShardId: doId.options.baseShardId,
numberOfReplicas: numReplicas,
shardType,
replicaId: doId.replicaId,
region,
tag,
}),
tag,
};
});
});
return regionalReplicasInAllRegions;
}
getTagType(tag: string): TagType {
if (tag.includes('change-request:')) {
return 'change-request';
}
if (tag.startsWith('release:') || tag.startsWith('integration:')) {
// Maybe use platform instead of internal ??
return 'internal';
}
if (tag.startsWith('site:') || tag.startsWith('url:')) {
// TODO: find a better name here.
// BTW do we want to append something like the organization to these cache tag
return 'global';
}
if (tag.startsWith('organization:')) {
return 'organization';
}
return 'space';
}
/**
*
* @returns The closest region to the user, if not found we default to "enam"
*/
getClosestRegion() {
const continent = getCloudflareContext().cf?.continent;
if (!continent) return 'enam';
switch (continent) {
case 'AF':
return 'afr';
case 'AS':
return 'apac';
case 'EU':
return 'weur';
case 'NA':
return 'enam';
case 'OC':
return 'oc';
case 'SA':
return 'sam';
default:
return 'enam';
}
}
// Cache API
async getCacheInstance() {
if (!this.localCache) {
this.localCache = await caches.open('sharded-do-tag-cache');
}
return this.localCache;
}
getCacheUrlKey(opts: CacheTagKeyOptions) {
const { doId, tags } = opts;
return `http://local.cache/shard/${doId.shardId}?tags=${encodeURIComponent(tags.join(';'))}`;
}
async getFromRegionalCache(opts: CacheTagKeyOptions) {
try {
const cache = await this.getCacheInstance();
if (!cache) return;
return cache.match(this.getCacheUrlKey(opts));
} catch (e) {
console.error('Error while fetching from regional cache', e);
}
}
async putToRegionalCache(optsKey: CacheTagKeyOptions, value: boolean) {
const cache = await this.getCacheInstance();
if (!cache) return;
const tags = optsKey.tags;
await cache.put(
this.getCacheUrlKey(optsKey),
new Response(`${value}`, {
headers: {
'cache-control': `max-age=${this.config.ttl[optsKey.doId.options.shardType] ?? 5}`,
...(tags.length > 0
? {
'cache-tag': tags.join(','),
}
: {}),
},
})
);
}
async deleteRegionalCache(optsKey: CacheTagKeyOptions) {
// We never want to crash because of the cache
try {
const cache = await this.getCacheInstance();
if (!cache) return;
await cache.delete(this.getCacheUrlKey(optsKey));
} catch (e) {
console.warn(e);
}
}
}
export default new GitbookTagCache({
numberOfShards: {
'change-request': 1,
global: 10,
internal: 10,
space: 10,
organization: 1,
},
} satisfies NextModeTagCache;
numberOfReplicas: {
'change-request': 1,
global: 2,
internal: 2,
space: 1,
organization: 1,
},
ttl: {
'change-request': 60,
global: 3600,
internal: 86400,
space: 3600,
organization: 86400,
},
});
+1 -1
View File
@@ -5,7 +5,7 @@
"dependencies": {
"@gitbook/api": "^0.119.0",
"@gitbook/cache-tags": "workspace:*",
"@opennextjs/cloudflare": "1.1.0",
"@opennextjs/cloudflare": "1.2.1",
"@sindresorhus/fnv1a": "^3.1.0",
"assert-never": "^1.2.1",
"jwt-decode": "^4.0.0",
+2 -1
View File
@@ -226,7 +226,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
const customization = siteRequestURL.searchParams.get('customization');
if (customization && validateSerializedCustomization(customization)) {
routeType = 'dynamic';
requestHeaders.set(MiddlewareHeaders.Customization, customization);
// We need to encode the customization headers, otherwise it will fail for some customization values containing non ASCII chars on vercel.
requestHeaders.set(MiddlewareHeaders.Customization, encodeURIComponent(customization));
}
const theme = siteRequestURL.searchParams.get('theme');
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
+1
View File
@@ -666,6 +666,7 @@ const testCases: TestsCase[] = [
name: 'Stepper',
url: 'blocks/stepper',
},
{ name: 'Columns', url: 'blocks/columns' },
],
},
{
@@ -12,6 +12,7 @@ import type { ClassValue } from '@/lib/tailwind';
import { BlockContentRef } from './BlockContentRef';
import { CodeBlock } from './CodeBlock';
import { Columns } from './Columns';
import { Divider } from './Divider';
import type { DocumentContextProps } from './DocumentView';
import { Drawing } from './Drawing';
@@ -68,6 +69,8 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
return <List {...props} block={block} />;
case 'list-item':
return <ListItem {...props} block={block} />;
case 'columns':
return <Columns {...props} block={block} />;
case 'code':
return <CodeBlock {...props} block={block} />;
case 'hint':
@@ -112,10 +115,8 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
case 'image':
case 'code-line':
case 'tabs-item':
throw new Error(`Blocks (${block.type}) should be directly rendered by parent`);
case 'columns':
case 'column':
return null;
throw new Error(`Blocks (${block.type}) should be directly rendered by parent`);
default:
return nullIfNever(block);
}
@@ -171,6 +172,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'integration':
case 'stepper':
case 'reusable-content':
case 'columns':
return <SkeletonCard id={id} style={style} />;
case 'embed':
case 'images':
@@ -179,10 +181,8 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'image':
case 'code-line':
case 'tabs-item':
throw new Error(`Blocks (${block.type}) should be directly rendered by parent`);
case 'columns':
case 'column':
return null;
throw new Error(`Blocks (${block.type}) should be directly rendered by parent`);
default:
return nullIfNever(block);
}
@@ -0,0 +1,80 @@
import { type ClassValue, tcls } from '@/lib/tailwind';
import type { DocumentBlockColumns, Length } from '@gitbook/api';
import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
export function Columns(props: BlockProps<DocumentBlockColumns>) {
const { block, style, ancestorBlocks, document, context } = props;
return (
<div className={tcls('flex flex-col gap-x-8 md:flex-row', style)}>
{block.nodes.map((columnBlock) => {
const width = columnBlock.data.width;
const { className, style } = transformLengthToCSS(width);
return (
<Column key={columnBlock.key} className={className} style={style}>
<Blocks
key={columnBlock.key}
nodes={columnBlock.nodes}
document={document}
ancestorBlocks={[...ancestorBlocks, block, columnBlock]}
context={context}
blockStyle="flip-heading-hash"
style="w-full space-y-4"
/>
</Column>
);
})}
</div>
);
}
export function Column(props: {
children?: React.ReactNode;
className?: ClassValue;
style?: React.CSSProperties;
}) {
return (
<div className={tcls('flex-col', props.className)} style={props.style}>
{props.children}
</div>
);
}
function transformLengthToCSS(length: Length | undefined) {
if (!length) {
return { className: ['md:w-full'] }; // default to full width if no length is specified
}
if (typeof length === 'number') {
return { style: undefined }; // not implemented yet with non-percentage lengths
}
if (length.unit === '%') {
return {
className: [
'md:flex-shrink-0',
COLUMN_WIDTHS[Math.round(length.value * 0.01 * (COLUMN_WIDTHS.length - 1))],
],
};
}
return { style: undefined }; // not implemented yet with non-percentage lengths
}
// Tailwind CSS classes for column widths.
// The index of the array corresponds to the percentage width of the column.
const COLUMN_WIDTHS = [
'md:w-0',
'md:w-1/12',
'md:w-2/12',
'md:w-3/12',
'md:w-4/12',
'md:w-5/12',
'md:w-6/12',
'md:w-7/12',
'md:w-8/12',
'md:w-9/12',
'md:w-10/12',
'md:w-11/12',
'md:w-full',
];
@@ -0,0 +1 @@
export * from './Columns';
+4 -1
View File
@@ -12,9 +12,12 @@ export async function getDynamicCustomizationSettings(
): Promise<SiteCustomizationSettings> {
const headersList = await headers();
const extend = headersList.get(MiddlewareHeaders.Customization);
if (extend) {
try {
const parsedSettings = rison.decode_object<SiteCustomizationSettings>(extend);
// We need to decode it first as it is URL encoded, then decode the Rison object
const unencoded = decodeURIComponent(extend);
const parsedSettings = rison.decode_object<SiteCustomizationSettings>(unencoded);
return parsedSettings;
} catch (_error) {}
+3 -1
View File
@@ -201,7 +201,9 @@ export async function middleware(request: NextRequest) {
const customization = url.searchParams.get('customization');
if (customization && validateSerializedCustomization(customization)) {
headers.set(MiddlewareHeaders.Customization, customization);
// We need to encode the customization to ensure it is properly encoded in vercel.
// We do it here as well so that we have a single method to decode it later.
headers.set(MiddlewareHeaders.Customization, encodeURIComponent(customization));
}
const theme = url.searchParams.get('theme');