mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-08 06:11:38 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c41cf9c12a | |||
| 3d376845d7 | |||
| bcd4d4e980 | |||
| 88b1919a45 | |||
| cf94386c16 | |||
| e77bf32d51 | |||
| a69a307de2 | |||
| 39156ee9c3 | |||
| 47ac3e2c81 | |||
| e73b182925 | |||
| a9a52fee72 | |||
| cb92754575 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add a carousel layout option to cards blocks, rendering them as a horizontally-scrolling, scroll-snapping row instead of a wrapping grid.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/colors": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
A near-white tint color (e.g. a warm `#F5F3EF`) is now taken as the exact page background, mirroring the existing behavior for near-black tints. The tint's exact lightness, hue and chroma are preserved, and the color is anchored to whichever scale step the active theme renders as the background — so it matches exactly on `muted` (which uses the second step) as well as `clean`. This applies only to near-neutral tints that are light enough to read as a background; saturated or merely light-ish colors keep their normal accent scale. The `bold` theme is unaffected: it already uses the tint for the header and stays intentionally two-tone.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add cover image background mode and masks
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Button blocks now respect the `size` option, so you can render small, medium, or large buttons.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Remove GBO's redundant re-selection of the best-scoring search section. The search API now returns a single highest-scoring section per page (and orders sections highest-score-first), so GBO no longer needs its own `getBestScoredResult` helper to pick the best section for the search and MCP previews. No user-visible change.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the docs embed widget shipping a stale script: declare the embed package's `standalone/` bundle as a Turbo build output. Because it wasn't declared, changes confined to the standalone widget (which compiles to `standalone/` but not `dist/`) didn't invalidate the downstream `generate` cache that copies it into the app, so the deployed widget could lag the source — e.g. the `clipboard-write` permission on the widget iframe never reached production, breaking the copy button in the Assistant embed.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/react-contentkit": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Let integration block webframes navigate the reader to another page in the site by posting a `@webframe.navigate` action with a `path` (and optional `anchor`). Resolved client-side against the site base path, so navigation stays in-site and drives the standard navigation progress bar.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix an issue where certain keywords could cause an exception when rendering emojis
|
||||
@@ -1,3 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="./assets/gitbook_icon_dark.svg" alt="GitBook" width="48" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">GitBook</h1>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="65" height="65" viewBox="0 0 65 65" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M27.3963 34.2195C30.5255 36.0255 32.09 36.9285 33.8082 36.93C35.5265 36.9315 37.0926 36.0313 40.2249 34.2308L60.1913 22.7534C61.0927 22.2353 61.6484 21.2749 61.6484 20.2352C61.6484 19.1955 61.0927 18.2351 60.1913 17.717L40.2177 6.23554C37.0888 4.43695 35.5243 3.53766 33.8078 3.53833C32.0912 3.539 30.5275 4.43951 27.4 6.24053L10.2292 16.1286C10.102 16.2019 10.0383 16.2385 9.97905 16.2732C4.11368 19.7068 0.489862 25.9754 0.441408 32.7717C0.440918 32.8404 0.440918 32.9138 0.440918 33.0607C0.440918 33.2074 0.440918 33.2807 0.441407 33.3494C0.489754 40.138 4.10549 46.4008 9.96041 49.837C10.0196 49.8718 10.0831 49.9085 10.2101 49.9818L20.9658 56.1918C27.2331 59.8104 30.3668 61.6197 33.808 61.6208C37.2493 61.622 40.3842 59.8148 46.6539 56.2005L58.008 49.6551C61.1474 47.8454 62.7171 46.9405 63.579 45.4487C64.4409 43.957 64.4409 42.1451 64.4409 38.5215V31.5212C64.4409 30.5159 63.8965 29.5895 63.0182 29.1004C62.1683 28.627 61.1325 28.6341 60.2891 29.1189L37.0074 42.5019C35.4453 43.3998 34.6643 43.8487 33.8072 43.849C32.9501 43.8493 32.1688 43.4008 30.6062 42.5038L14.8487 33.4586C14.0593 33.0055 13.6647 32.7789 13.3477 32.738C12.625 32.6448 11.9301 33.0497 11.6548 33.7244C11.534 34.0203 11.5365 34.4753 11.5414 35.3855C11.545 36.0555 11.5468 36.3905 11.6094 36.6987C11.7496 37.3887 12.1127 38.0136 12.6428 38.4771C12.8795 38.6842 13.1696 38.8516 13.7499 39.1866L30.5974 48.9103C32.164 49.8145 32.9473 50.2666 33.8075 50.2668C34.6677 50.267 35.4512 49.8154 37.0184 48.912L57.6684 37.0085C58.2037 36.7 58.4713 36.5457 58.672 36.6616C58.8727 36.7776 58.8727 37.0865 58.8727 37.7044V40.8796C58.8727 41.7855 58.8727 42.2385 58.6572 42.6114C58.4417 42.9844 58.0493 43.2106 57.2644 43.663L40.2322 53.4811C37.0966 55.2885 35.5288 56.1923 33.8078 56.1915C32.0869 56.1907 30.5199 55.2855 27.386 53.4752L11.4509 44.2701C11.4003 44.2409 11.375 44.2262 11.3514 44.2125C8.0102 42.26 5.94856 38.6882 5.92922 34.8185C5.92909 34.7911 5.92909 34.7619 5.92909 34.7035V31.7889C5.92909 29.6526 7.06686 27.678 8.9151 26.6067C10.5483 25.66 12.5628 25.6582 14.1977 26.6018L27.3963 34.2195Z" fill="#181C1F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -360,7 +360,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.187.0",
|
||||
"@gitbook/api": "0.189.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -756,7 +756,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.187.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ec2TuMm19ycPzFZXUt2D7xSNjbF3vh9Ft3zTgG4Td4UkVuNiB+zpRYmnduqzE0XsiMEwwKLEj/HWMXe+QK4Piw=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.189.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-QezuW8dMScSTJ+2IL/GGixkhGEsMRFRoO/W2r80pKRjoWyI9Ebai9oZsoN/pbRZz3cTnwa7hpRDtzaqHeeOAdQ=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.187.0",
|
||||
"@gitbook/api": "0.189.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"unit": "bun test",
|
||||
"dev": "bun run build -- --watch ./src",
|
||||
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { colorScale } from './transformations';
|
||||
|
||||
describe('colorScale exact base', () => {
|
||||
it('takes a very light tint as the exact background on step 1 (clean/bold)', () => {
|
||||
const scale = colorScale('#F5F3EF', { baseStep: 1 });
|
||||
expect(scale[0]).toBe('#F5F3EF');
|
||||
});
|
||||
|
||||
it('anchors a very light tint to step 2 when the theme uses the subtle step (muted)', () => {
|
||||
const scale = colorScale('#F5F3EF', { baseStep: 2 });
|
||||
expect(scale[1]).toBe('#F5F3EF');
|
||||
// Step 1 sits just above the exact base, toward white.
|
||||
expect(scale[0]).not.toBe('#F5F3EF');
|
||||
expect(scale[0]).not.toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('takes a darker-than-dark tint as the exact background, preserving hue and chroma', () => {
|
||||
const scale = colorScale('#0B0F19', { darkMode: true, baseStep: 1 });
|
||||
expect(scale[0]).toBe('#0B0F19');
|
||||
});
|
||||
|
||||
it('does not trigger for a normal mid-lightness tint', () => {
|
||||
const scale = colorScale('#787878', { baseStep: 2 });
|
||||
// The default white background is kept; the tint only colors the scale.
|
||||
expect(scale[0]).toBe('#ffffff');
|
||||
expect(scale[1]).not.toBe('#787878');
|
||||
});
|
||||
|
||||
it('does not trigger for a saturated light color, keeping the normal accent ramp', () => {
|
||||
// Light enough (L≈0.93) to pass the lightness bound, but too chromatic to read as a
|
||||
// background — emitting it verbatim would leave a vivid step 1 above a near-gray scale.
|
||||
const scale = colorScale('#FFEB3B', { baseStep: 1 });
|
||||
expect(scale[0]).toBe('#ffffff');
|
||||
expect(scale[0]).not.toBe('#FFEB3B');
|
||||
});
|
||||
|
||||
it('respects a custom light background instead of overriding it with the tint', () => {
|
||||
// The color is darker than the requested background, so it is not the extreme end and the
|
||||
// supplied base must be preserved rather than overwritten.
|
||||
const scale = colorScale('#eeeeee', { baseStep: 1, background: '#f8f8f8' });
|
||||
expect(scale[0]).not.toBe('#eeeeee');
|
||||
});
|
||||
|
||||
it('anchors an exact base even when a neutral mix is supplied (tint === primary)', () => {
|
||||
// getTintMixColor blends neutral into the tint when it equals the primary color; that must
|
||||
// not darken a near-white tint out of the exact-base path.
|
||||
const scale = colorScale('#F5F3EF', {
|
||||
baseStep: 1,
|
||||
mix: { color: '#787878', ratio: 0.4 },
|
||||
});
|
||||
expect(scale[0]).toBe('#F5F3EF');
|
||||
});
|
||||
|
||||
it('does not trigger for a light accent color below the near-white threshold', () => {
|
||||
// #D8DEEC (light blue-gray, L≈0.90) is a UI accent, not a background, so it must not anchor.
|
||||
const scale = colorScale('#D8DEEC', { baseStep: 1 });
|
||||
expect(scale[0]).toBe('#ffffff');
|
||||
expect(scale[0]).not.toBe('#D8DEEC');
|
||||
});
|
||||
|
||||
it('never anchors when no baseStep is given (accent scales and the bold theme)', () => {
|
||||
// A scale that does not define the page background opts out of the exact base entirely.
|
||||
const scale = colorScale('#F5F3EF', {});
|
||||
expect(scale[0]).toBe('#ffffff');
|
||||
expect(scale[0]).not.toBe('#F5F3EF');
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,22 @@ export const colorMixMapping = {
|
||||
dark: [0, 0.03, 0.08, 0.1, 0.13, 0.15, 0.2, 0.25, 0.5, 0.55, 0.75, 1],
|
||||
};
|
||||
|
||||
/**
|
||||
* Light mode has no equivalent to the dark base bound (nothing is lighter than white), so a tint
|
||||
* at or above this lightness is treated as an explicit, near-white background (e.g. a warm `#F5F3EF`
|
||||
* at L≈0.96). Kept high so light UI accent colors (around L≈0.90) aren't mistaken for backgrounds.
|
||||
*/
|
||||
const EXACT_BASE_LIGHT_THRESHOLD = 0.95;
|
||||
|
||||
/**
|
||||
* Only a near-neutral tint reads as a background. A saturated color would keep the exact hue at the
|
||||
* anchored step while the rest of the low scale stays ~gray, so those keep the normal accent ramp.
|
||||
*/
|
||||
const EXACT_BASE_NEUTRAL_CHROMA = 0.05;
|
||||
|
||||
/** Lightness of the default white light background (≈0.99999, not exactly 1). */
|
||||
const LIGHT_BASE_L = rgbToOklch(hexToRgbArray(LIGHT_BASE)).L;
|
||||
|
||||
/**
|
||||
* Convert a hex color to an RGB color.
|
||||
*/
|
||||
@@ -159,6 +175,14 @@ export type ColorScaleOptions = {
|
||||
/** Define a custom foreground color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */
|
||||
foreground?: string;
|
||||
|
||||
/**
|
||||
* The 1-indexed scale step this scale renders as the page background (1 = `tint-base` for
|
||||
* `clean`, 2 = `tint-subtle` for `muted`). When set, an extreme near-neutral tint is taken as
|
||||
* the exact background, anchored to this step so it matches exactly. Omit for scales that don't
|
||||
* define the page background (accents, or the two-tone `bold` theme) — they never anchor.
|
||||
*/
|
||||
baseStep?: number;
|
||||
|
||||
mix?: {
|
||||
/** If set to a hex code, this color will be additionally mixed into the generated scale according to `mix.ratio`. */
|
||||
color: string;
|
||||
@@ -179,6 +203,7 @@ export function colorScale(
|
||||
darkMode = false,
|
||||
background = darkMode ? DARK_BASE : LIGHT_BASE,
|
||||
foreground = darkMode ? LIGHT_BASE : DARK_BASE,
|
||||
baseStep,
|
||||
mix,
|
||||
}: ColorScaleOptions = {}
|
||||
) {
|
||||
@@ -188,31 +213,51 @@ export function colorScale(
|
||||
const backgroundColor = rgbToOklch(hexToRgbArray(background));
|
||||
let mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light;
|
||||
|
||||
if (mixColor && mix?.ratio && mix.ratio > 0) {
|
||||
// If defined, we mix in a (tiny) bit of the mix color with the base color.
|
||||
// A near-neutral tint at the extreme end of the scale is taken as the exact page background
|
||||
// rather than tinting pure black/white with it — letting brands set an exact background such as
|
||||
// a warm `#F5F3EF`. Only scales that define the page background opt in (via `baseStep`). In light
|
||||
// mode the base is pure white by default, so a near-white tint also qualifies (nothing is lighter
|
||||
// than white); a custom, lower background is respected instead. Decided on the raw color so a
|
||||
// neutral mix (below) can't darken a tint out of the exact base.
|
||||
const isExtremeBase = darkMode
|
||||
? baseColor.L < backgroundColor.L
|
||||
: backgroundColor.L >= LIGHT_BASE_L
|
||||
? baseColor.L > EXACT_BASE_LIGHT_THRESHOLD
|
||||
: baseColor.L > backgroundColor.L;
|
||||
const isExactBase =
|
||||
baseStep !== undefined && isExtremeBase && baseColor.C < EXACT_BASE_NEUTRAL_CHROMA;
|
||||
const exactBaseIndex = (baseStep ?? 1) - 1;
|
||||
|
||||
if (mixColor && mix?.ratio && mix.ratio > 0 && !isExactBase) {
|
||||
// Mix a little of the mix color into the base — but not when the tint is the exact base,
|
||||
// where it must stay true to the supplied color (and match `--header-background`).
|
||||
baseColor.L = mixColor.L * mix.ratio + baseColor.L * (1 - mix.ratio);
|
||||
baseColor.C = mixColor.C * mix.ratio + baseColor.C * (1 - mix.ratio);
|
||||
baseColor.H = mix.color === DEFAULT_TINT_COLOR ? baseColor.H : mixColor.H;
|
||||
}
|
||||
|
||||
if (
|
||||
(darkMode && baseColor.L < backgroundColor.L) ||
|
||||
(!darkMode && baseColor.L > backgroundColor.L)
|
||||
) {
|
||||
// If the supplied color is outside of our lightness bounds, use the supplied color's lightness.
|
||||
// This is mostly used to allow darker-than-dark backgrounds for brands that specifically want that look.
|
||||
if (isExactBase) {
|
||||
const difference = (backgroundColor.L - baseColor.L) / backgroundColor.L;
|
||||
backgroundColor.L = baseColor.L;
|
||||
// At the edges of the scale, the subtle lightness changes stop being perceptible. We need to amp up our mapping to still stand out.
|
||||
const amplifier = 1;
|
||||
mapping = mapping.map((step, index) =>
|
||||
index < 9 ? step + step * amplifier * difference : step
|
||||
);
|
||||
|
||||
// Anchor the supplied color to the step the theme renders as the background, solving the
|
||||
// background lightness so neighbouring steps stay continuous with it.
|
||||
const baseMix = mapping[exactBaseIndex]!;
|
||||
backgroundColor.L = (baseColor.L - foregroundColor.L * baseMix) / (1 - baseMix);
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
for (let index = 0; index < mapping.length; index++) {
|
||||
if (isExactBase && index === exactBaseIndex) {
|
||||
result.push(hex);
|
||||
continue;
|
||||
}
|
||||
|
||||
const step = mapping[index]!;
|
||||
const targetL = foregroundColor.L * step + backgroundColor.L * (1 - step);
|
||||
|
||||
@@ -238,7 +283,10 @@ export function colorScale(
|
||||
case 11:
|
||||
return 0.1;
|
||||
default:
|
||||
return index * 0.05;
|
||||
// When the tint is the exact base, hold the steps from the base toward the
|
||||
// accents at its chroma so the background stays tinted; steps lighter than the
|
||||
// base (e.g. cards in `muted`) keep desaturating toward white.
|
||||
return isExactBase && index >= exactBaseIndex ? 1 : index * 0.05;
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": ["dist/**", "standalone/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,8 +218,8 @@ const testCases: TestsCase[] = [
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.cherry-ai.com',
|
||||
contentBaseURL: 'https://docs.cherry-ai.com',
|
||||
name: 'docs.cherryai.com.cn',
|
||||
contentBaseURL: 'https://docs.cherryai.com.cn',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
|
||||
+4
-3
@@ -11,7 +11,6 @@ import { getExposableError, throwIfDataError } from '@/lib/data';
|
||||
import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage';
|
||||
import { resolvePagePath } from '@/lib/pages';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { getBestScoredResult } from '@/lib/search';
|
||||
import { findSiteSpaceBy, findSiteSpaceByUrl } from '@/lib/sites';
|
||||
import { trackServerInsightsEvents } from '@/lib/tracking';
|
||||
import { waitUntil } from '@/lib/waitUntil';
|
||||
@@ -135,8 +134,10 @@ export async function handleMcpRequest(
|
||||
)
|
||||
);
|
||||
|
||||
const body = getBestScoredResult(
|
||||
(pageResult.sections ?? []).filter((section) => section.body)
|
||||
// The search API returns sections ordered highest-score-first, so
|
||||
// the first section with a body is the best-scoring preview.
|
||||
const body = (pageResult.sections ?? []).find(
|
||||
(section) => section.body
|
||||
)?.body;
|
||||
|
||||
return {
|
||||
|
||||
+3
-3
@@ -8,7 +8,6 @@ import { throwIfDataError } from '@/lib/data';
|
||||
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { getBestScoredResult } from '@/lib/search';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { findSiteSpaceBy, getLocalizedTitle } from '@/lib/sites';
|
||||
import type {
|
||||
@@ -187,8 +186,9 @@ function transformSitePageResult(args: {
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// Find the best-scoring section to use as a body preview on the page result.
|
||||
const bestSection = getBestScoredResult(pageSections);
|
||||
// The search API returns each page's sections ordered highest-score-first and caps them at one
|
||||
// per page, so the first section is the best-scoring one to use as a body preview.
|
||||
const bestSection = pageSections[0];
|
||||
if (bestSection) {
|
||||
page.bestSection = {
|
||||
href: bestSection.href,
|
||||
|
||||
@@ -36,6 +36,14 @@ export interface DocumentContext {
|
||||
* @default false
|
||||
*/
|
||||
withLinkPreviews?: boolean;
|
||||
|
||||
/**
|
||||
* True when this document is the main page body — the only place a background page cover is
|
||||
* shown behind the content. Cover-aware text styling (contrast over the cover) is scoped to it
|
||||
* so auxiliary documents (search answers, AI chat, PDF export) don't inherit the cover colors.
|
||||
* @default false
|
||||
*/
|
||||
isPageBody?: boolean;
|
||||
}
|
||||
|
||||
export interface DocumentContextProps {
|
||||
|
||||
@@ -59,6 +59,9 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
|
||||
'justify-self-start',
|
||||
'max-w-full',
|
||||
'break-words',
|
||||
// Cover-aware contrast text applies only to the page body, not to documents
|
||||
// rendered in overlays (search answers, AI chat) on a background-cover page.
|
||||
context.isPageBody && 'page-cover-background:text-contrast-cover',
|
||||
getTextAlignment(block.data.align),
|
||||
textStyle.lineHeight
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,14 @@ import { useAI, useAIChatController, useAIChatState } from '../AI';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import { Button, type ButtonProps, Input } from '../primitives';
|
||||
|
||||
// The Input primitive has no `xsmall`; otherwise it shares the button's size scale.
|
||||
const INPUT_SIZE_MAP: Record<NonNullable<ButtonProps['size']>, 'small' | 'medium' | 'large'> = {
|
||||
xsmall: 'small',
|
||||
small: 'small',
|
||||
medium: 'medium',
|
||||
large: 'large',
|
||||
};
|
||||
|
||||
export function InlineActionButton(
|
||||
props: { action: 'ask' | 'search'; query?: string } & { buttonProps: ButtonProps } // TODO: Type this properly: Pick<api.DocumentInlineButton, 'action' | 'query'> & { buttonProps: ButtonProps }
|
||||
) {
|
||||
@@ -42,7 +50,7 @@ export function InlineActionButton(
|
||||
<Input
|
||||
inline
|
||||
label={buttonProps.label as string}
|
||||
sizing="medium"
|
||||
sizing={INPUT_SIZE_MAP[buttonProps.size ?? 'medium']}
|
||||
className="inline-flex max-w-full grow"
|
||||
submitButton={{
|
||||
label: tString(language, action === 'ask' ? 'send' : 'search'),
|
||||
|
||||
@@ -9,6 +9,16 @@ import type { InlineProps } from './Inline';
|
||||
import { InlineActionButton } from './InlineActionButton';
|
||||
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
|
||||
|
||||
// Editor button sizes render one step smaller here; the editor default (`large`) keeps the previous `medium`.
|
||||
const BUTTON_SIZE_MAP: Record<
|
||||
NonNullable<api.DocumentInlineButton['data']['size']>,
|
||||
ButtonProps['size']
|
||||
> = {
|
||||
small: 'xsmall',
|
||||
medium: 'small',
|
||||
large: 'medium',
|
||||
};
|
||||
|
||||
export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
const { inline, context } = props;
|
||||
|
||||
@@ -16,7 +26,7 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
label: inline.data.label,
|
||||
variant: inline.data.kind,
|
||||
icon: inline.data.icon as IconName | undefined,
|
||||
size: 'medium',
|
||||
size: BUTTON_SIZE_MAP[inline.data.size ?? 'large'],
|
||||
};
|
||||
|
||||
const ButtonImplementation = () => {
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
|
||||
import React from 'react';
|
||||
|
||||
type ContentKitProps<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
|
||||
|
||||
/**
|
||||
* ContentKit wrapper for integration blocks that need client-only adaptive context.
|
||||
*/
|
||||
export function ContentKitWithAdaptiveVisitorContext<RenderContext>(
|
||||
props: ContentKitProps<RenderContext>
|
||||
) {
|
||||
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
|
||||
const visitorClaims = getAdaptiveVisitorClaims();
|
||||
|
||||
const clientContext = React.useMemo<ContentKitClientContextData>(
|
||||
() => ({
|
||||
getVisitorContext: () => ({
|
||||
visitor: visitorClaims?.visitor ?? null,
|
||||
}),
|
||||
}),
|
||||
[visitorClaims]
|
||||
);
|
||||
|
||||
return <ContentKit {...props} clientContext={clientContext} />;
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { NavigationStatusContext } from '@/components/hooks';
|
||||
import { type GitBookLinker, createLinker } from '@/lib/links';
|
||||
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
type ContentKitProps<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
|
||||
|
||||
/** Serializable inputs to rebuild the tested linker on the client (functions can't cross the RSC boundary). */
|
||||
export type WebframeLinkerData = Pick<
|
||||
Parameters<typeof createLinker>[0],
|
||||
'host' | 'protocol' | 'siteBasePath' | 'spaceBasePath'
|
||||
>;
|
||||
|
||||
/**
|
||||
* ContentKit wrapper for integration blocks that expose client-only capabilities to webframes:
|
||||
* navigation to other pages, and adaptive visitor claims (only when the integration is allowed to
|
||||
* access them).
|
||||
*/
|
||||
export function ContentKitWithClientContext<RenderContext>(
|
||||
props: ContentKitProps<RenderContext> & {
|
||||
/** Whether visitor claims may be exposed to the webframe (integration scope gated). */
|
||||
canAccessVisitorClaims: boolean;
|
||||
/** Data to rebuild the site linker, used to resolve webframe navigation requests. */
|
||||
linkerData: WebframeLinkerData;
|
||||
}
|
||||
) {
|
||||
const { canAccessVisitorClaims, linkerData, ...contentKitProps } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { onNavigationClick } = React.useContext(NavigationStatusContext);
|
||||
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
|
||||
|
||||
// Rebuild the (tested) linker on the client so navigation resolves paths exactly like the rest
|
||||
// of the app, instead of duplicating the join logic here.
|
||||
const linker = React.useMemo<GitBookLinker>(() => createLinker(linkerData), [linkerData]);
|
||||
|
||||
// Navigate to an in-site href, driving the same navigation progress bar as a regular link so
|
||||
// the reader gets feedback while the destination page loads.
|
||||
const navigateTo = React.useCallback(
|
||||
(href: string) => {
|
||||
onNavigationClick(href);
|
||||
router.push(href);
|
||||
},
|
||||
[onNavigationClick, router]
|
||||
);
|
||||
// Read during render (Suspense) only when the integration is allowed visitor claims, so that
|
||||
// webframes that don't use visitor claims don't suspend on the visitor-claims fetch.
|
||||
const visitorClaims = canAccessVisitorClaims ? getAdaptiveVisitorClaims() : null;
|
||||
|
||||
const clientContext = React.useMemo<ContentKitClientContextData>(
|
||||
() => ({
|
||||
getVisitorContext: canAccessVisitorClaims
|
||||
? () => ({ visitor: visitorClaims?.visitor ?? null })
|
||||
: undefined,
|
||||
navigate: ({ path, anchor }) => {
|
||||
// Resolve the requested path relative to the site root so a webframe can navigate
|
||||
// to any section or space within the site (and nowhere outside it).
|
||||
const suffix = anchor ? `#${anchor}` : '';
|
||||
navigateTo(linker.toPathInSite(path) + suffix);
|
||||
},
|
||||
}),
|
||||
[canAccessVisitorClaims, visitorClaims, linker, navigateTo]
|
||||
);
|
||||
|
||||
return <ContentKit {...contentKitProps} clientContext={clientContext} />;
|
||||
}
|
||||
@@ -5,8 +5,12 @@ import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import './contentkit.css';
|
||||
import { ContentKitWithAdaptiveVisitorContext } from './ContentKitWithAdaptiveVisitorContext';
|
||||
import { shouldRenderIntegrationBlockWithAdaptiveVisitorContext } from './adaptive';
|
||||
import type { GitBookLinker } from '@/lib/links';
|
||||
import {
|
||||
ContentKitWithClientContext,
|
||||
type WebframeLinkerData,
|
||||
} from './ContentKitWithClientContext';
|
||||
import { integrationBlockContainsWebframe } from './adaptive';
|
||||
import { contentKitServerContext } from './contentkit';
|
||||
import { fetchSafeIntegrationUI } from './render';
|
||||
import { renderIntegrationUi } from './server-actions';
|
||||
@@ -70,34 +74,70 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
return null;
|
||||
}
|
||||
|
||||
const ContentKitComponent = shouldRenderIntegrationBlockWithAdaptiveVisitorContext(
|
||||
initialOutput
|
||||
)
|
||||
? ContentKitWithAdaptiveVisitorContext
|
||||
: ContentKit;
|
||||
const containsWebframe = integrationBlockContainsWebframe(initialOutput);
|
||||
const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true;
|
||||
|
||||
// Any webframe uses the client-context wrapper: it enables navigation to other pages, plus
|
||||
// visitor claims when the integration is allowed them.
|
||||
const useClientContext = containsWebframe;
|
||||
|
||||
const contentKitProps = {
|
||||
renderContext: {
|
||||
integrationName: block.data.integration,
|
||||
},
|
||||
security: {
|
||||
// Trust both the integrations host and the (cookieless) content host that
|
||||
// serves rendered WebFrames. `ElementWebframe` gates inbound and outbound
|
||||
// postMessage on this list, so a WebFrame served from the content host would
|
||||
// break (no resize/ready/actions) if the content host weren't trusted.
|
||||
// The hosts are identical until a distinct content origin is configured.
|
||||
firstPartyDomains: [
|
||||
...new Set([GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST]),
|
||||
],
|
||||
},
|
||||
initialInput,
|
||||
initialOutput,
|
||||
render: renderIntegrationUi,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={tcls(style)}>
|
||||
<ContentKitComponent
|
||||
renderContext={{
|
||||
integrationName: block.data.integration,
|
||||
}}
|
||||
security={{
|
||||
// Trust both the integrations host and the (cookieless) content host that
|
||||
// serves rendered WebFrames. `ElementWebframe` gates inbound and outbound
|
||||
// postMessage on this list, so a WebFrame served from the content host would
|
||||
// break (no resize/ready/actions) if the content host weren't trusted.
|
||||
// The hosts are identical until a distinct content origin is configured.
|
||||
firstPartyDomains: [
|
||||
...new Set([GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST]),
|
||||
],
|
||||
}}
|
||||
initialInput={initialInput}
|
||||
initialOutput={initialOutput}
|
||||
render={renderIntegrationUi}
|
||||
>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
</ContentKitComponent>
|
||||
{useClientContext ? (
|
||||
<ContentKitWithClientContext
|
||||
{...contentKitProps}
|
||||
canAccessVisitorClaims={canAccessVisitorClaims}
|
||||
linkerData={getWebframeLinkerData(context.contentContext.linker)}
|
||||
>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
</ContentKitWithClientContext>
|
||||
) : (
|
||||
<ContentKit {...contentKitProps}>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
</ContentKit>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the serializable data needed to rebuild the site linker on the client, so webframe
|
||||
* navigation resolves paths through the same (tested) linker as the rest of the app.
|
||||
*/
|
||||
function getWebframeLinkerData(linker: GitBookLinker): WebframeLinkerData {
|
||||
const data: WebframeLinkerData = {
|
||||
siteBasePath: linker.siteBasePath,
|
||||
spaceBasePath: linker.spaceBasePath,
|
||||
};
|
||||
|
||||
// `host`/`protocol` are only used to build absolute URLs, which webframe navigation never does.
|
||||
// Carry them along when available so the rebuilt linker is complete (and avoids a dev warning).
|
||||
try {
|
||||
const url = new URL(linker.toAbsoluteURL('/'));
|
||||
data.host = url.host;
|
||||
data.protocol = url.protocol;
|
||||
} catch {
|
||||
// No usable host (e.g. tests): the linker still resolves in-site paths without it.
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api';
|
||||
|
||||
import { integrationBlockContainsWebframe } from './adaptive';
|
||||
|
||||
const webframe: ContentKitWebFrame = {
|
||||
type: 'webframe',
|
||||
source: { url: 'https://integrations.gitbook.com/frame' },
|
||||
};
|
||||
|
||||
function elementOutput(element: unknown): ContentKitRenderOutput {
|
||||
return {
|
||||
type: 'element',
|
||||
element,
|
||||
state: {},
|
||||
props: {},
|
||||
} as ContentKitRenderOutput;
|
||||
}
|
||||
|
||||
describe('integrationBlockContainsWebframe', () => {
|
||||
it('returns false for a completed output', () => {
|
||||
expect(integrationBlockContainsWebframe({ type: 'complete' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there is no webframe in the tree', () => {
|
||||
const output = elementOutput({
|
||||
type: 'block',
|
||||
children: [{ type: 'text', text: 'hello' }],
|
||||
} as never);
|
||||
expect(integrationBlockContainsWebframe(output)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when a webframe is nested in the tree', () => {
|
||||
const output = elementOutput({
|
||||
type: 'block',
|
||||
children: [{ type: 'vstack', children: [webframe] }],
|
||||
} as never);
|
||||
expect(integrationBlockContainsWebframe(output)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,19 +8,15 @@ import type {
|
||||
type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper;
|
||||
|
||||
/**
|
||||
* Decide whether an integration block should expose Adaptive visitor context to webframes.
|
||||
* Whether an integration block's output contains a webframe that can consume client-only context
|
||||
* (navigation and/or visitor claims).
|
||||
*/
|
||||
export function shouldRenderIntegrationBlockWithAdaptiveVisitorContext(
|
||||
output: ContentKitRenderOutput
|
||||
) {
|
||||
export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean {
|
||||
if (output.type === 'complete') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
output.canAccessVisitorClaims === true &&
|
||||
doesContentKitElementContainWebframe(output.element)
|
||||
);
|
||||
return doesContentKitElementContainWebframe(output.element);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,17 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
|
||||
'has-[.button,input]:flex has-[.button,input]:flex-wrap has-[.button,input]:gap-2 has-[.button,input]:items-center';
|
||||
|
||||
const paragraph = (
|
||||
<p className={tcls(inlineButtonStyle, style, getTextAlignment(block.data?.align))}>
|
||||
<p
|
||||
className={tcls(
|
||||
// Cover-aware contrast text applies only to the page body, not to documents
|
||||
// rendered in overlays (search answers, AI chat) on a background-cover page.
|
||||
context.isPageBody &&
|
||||
'page-cover-background:[&:not(:has(.button,input))]:text-contrast-cover',
|
||||
inlineButtonStyle,
|
||||
style,
|
||||
getTextAlignment(block.data?.align)
|
||||
)}
|
||||
>
|
||||
<Inlines {...contextProps} nodes={block.nodes} ancestorInlines={[]} />
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DocumentTableViewCards } from '@gitbook/api';
|
||||
|
||||
import { ScrollContainer } from '@/components/primitives/ScrollContainer';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { RecordCard } from './RecordCard';
|
||||
@@ -7,6 +8,20 @@ import type { TableViewProps } from './Table';
|
||||
import { TableSearchRecord } from './TableSearch';
|
||||
|
||||
export function ViewCards(props: TableViewProps<DocumentTableViewCards>) {
|
||||
// `wrap` defaults to `true` (a wrapping grid); only an explicit `false` opts into the
|
||||
// horizontally-scrolling carousel row. Fall back to the grid in print mode: a PDF can't
|
||||
// scroll, so carousel overflow would be silently clipped.
|
||||
if (props.view.wrap === false && props.context.mode !== 'print') {
|
||||
return <CardsCarousel {...props} />;
|
||||
}
|
||||
|
||||
return <CardsGrid {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default layout: cards wrap into a responsive grid.
|
||||
*/
|
||||
function CardsGrid(props: TableViewProps<DocumentTableViewCards>) {
|
||||
const { block, view, records, style } = props;
|
||||
|
||||
return (
|
||||
@@ -35,3 +50,130 @@ export function ViewCards(props: TableViewProps<DocumentTableViewCards>) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The carousel layout: cards lay out in a single horizontally-scrolling row that
|
||||
* snaps to the leftmost card. Reuses ScrollContainer for the scroll buttons.
|
||||
*
|
||||
* Rather than fading the edges, the row breaks out of the content column so it can
|
||||
* scroll to the page edges. Negative margins on the outer wrapper pull it out; matching
|
||||
* padding + scroll-padding on the scroller keep the first/last cards aligned with the
|
||||
* body text at rest and snapping to that edge, while cards bleed to the edge mid-scroll.
|
||||
* See `bleedVars` below for how far each side reaches.
|
||||
*/
|
||||
function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
|
||||
const { view, records } = props;
|
||||
|
||||
// Cards need a fixed width so the row overflows and scrolls; mirror the grid's
|
||||
// medium/large sizing.
|
||||
const cardWidth =
|
||||
view.cardSize === 'large'
|
||||
? 'w-[90%] @sm:w-[calc(45%-0.5rem)] @5xl:w-[calc(50%-0.5rem)]'
|
||||
: 'w-[90%] @sm:w-[calc(45%-0.5rem)] @xl:w-[calc(30%-0.66rem)] @5xl:w-[calc(33.33%-0.66rem)]';
|
||||
|
||||
// Break the row out of the content column so it bleeds to the page edges instead of
|
||||
// fading. `--cards-bleed-l/r` are the distances to pull each side out by; they drive the
|
||||
// negative margins (on the wrapper) and the matching padding + scroll-padding (on the
|
||||
// scroller), so the first/last cards stay aligned with the body text at rest while cards
|
||||
// bleed to the edge mid-scroll. Kept as literals here since it's a single block.
|
||||
//
|
||||
// The bleed only makes sense on the default layout, where the 48rem column leaves wide
|
||||
// empty margins to reclaim. The wide layout (max-w-6xl) already fills the usable width,
|
||||
// so from `lg` we suppress the bleed entirely — otherwise the page gutter would push the
|
||||
// row past where every other block ends, jutting into the window frame.
|
||||
//
|
||||
// On the default layout:
|
||||
// - Left is capped at the page gutter (1/1.5/2rem) so it never slides under the TOC.
|
||||
// - Right reaches the viewport edge from `lg`. The 48rem column is centred in the space
|
||||
// beside the TOC, so the gap to the viewport edge is `50vw` minus half the column
|
||||
// (24rem), minus half the TOC (10.5rem of the 21rem `w-72`+`mr-12`) when one is shown.
|
||||
// `html` clips horizontal overflow, so a small overshoot is harmless.
|
||||
// - Right collapses to 0 once an outline occupies that column (shown from `xl`), so
|
||||
// cards never slide under it.
|
||||
const bleedVars = tcls(
|
||||
'[--cards-bleed-l:1rem]',
|
||||
'sm:[--cards-bleed-l:1.5rem]',
|
||||
'md:[--cards-bleed-l:2rem]',
|
||||
'layout-default:md:max-lg:[--cards-bleed-l:max(calc(50vw-24.5rem),2rem)]',
|
||||
'lg:[--cards-bleed-l:max(calc(50vw-34rem),3rem)]',
|
||||
'xl:[--cards-bleed-l:3rem]',
|
||||
|
||||
'[--cards-bleed-r:1rem]',
|
||||
'sm:[--cards-bleed-r:1.5rem]',
|
||||
'md:[--cards-bleed-r:2rem]',
|
||||
'layout-default:md:max-lg:[--cards-bleed-r:max(calc(50vw-24.5rem),2rem)]',
|
||||
'layout-default:lg:[--cards-bleed-r:max(calc(50vw-35rem),3rem)]',
|
||||
'layout-default:xl:[--cards-bleed-r:3rem]',
|
||||
|
||||
'hover:layout-default:no-sidebar:lg:max-xl:[--cards-bleed-l:max(calc(50vw-24.5rem),2rem)]',
|
||||
'hover:layout-default:no-sidebar:lg:max-xl:[--cards-bleed-r:max(calc(50vw-24.5rem),2rem)]',
|
||||
|
||||
// Default centered
|
||||
'hover:layout-default:no-sidebar:xl:[--cards-bleed-l:max(calc(50vw-22.5rem),2rem)]',
|
||||
'hover:layout-default:xl:[--cards-bleed-r:max(calc(50vw-26.5rem),19rem)]',
|
||||
|
||||
// Full width, no outline
|
||||
'hover:layout-wide:page-no-outline:2xl:[--cards-bleed-r:max(calc(50vw-43.5rem),0rem)]',
|
||||
|
||||
// Full width centered
|
||||
'layout-wide:no-sidebar:page-no-outline:2xl:[--cards-bleed-l:max(calc(50vw-36.5rem),0rem)]',
|
||||
'layout-wide:no-sidebar:page-no-outline:2xl:[--cards-bleed-r:max(calc(50vw-36.5rem),0rem)]'
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollContainer
|
||||
orientation="horizontal"
|
||||
className={tcls(
|
||||
bleedVars,
|
||||
'ml-[calc(var(--cards-bleed-l)*-1)]',
|
||||
'mr-[calc(var(--cards-bleed-r)*-1)]',
|
||||
'xl:transition-[margin]',
|
||||
'hover:z-11'
|
||||
)}
|
||||
// `py-1` keeps the card ring/shadow from being clipped by the scroll overflow;
|
||||
// `snap-mandatory` + the scroll-padding snap each card to the content edge.
|
||||
contentClassName={tcls(
|
||||
'gap-4',
|
||||
'pt-px',
|
||||
'-mt-px',
|
||||
'pb-6',
|
||||
'-mb-6',
|
||||
'pl-[var(--cards-bleed-l)]',
|
||||
'pr-[var(--cards-bleed-r)]',
|
||||
'scroll-pl-[var(--cards-bleed-l)]',
|
||||
'scroll-pr-[var(--cards-bleed-r)]',
|
||||
'snap-x',
|
||||
'snap-mandatory',
|
||||
'xl:transition-[padding]'
|
||||
)}
|
||||
leading={{
|
||||
fade: true,
|
||||
button: { size: 'small', className: 'ml-[calc(var(--cards-bleed-l)-1rem)]' },
|
||||
}}
|
||||
trailing={{
|
||||
fade: true,
|
||||
button: { size: 'small', className: 'mr-[calc(var(--cards-bleed-r)-1rem)]' },
|
||||
}}
|
||||
>
|
||||
{records.map((record) => {
|
||||
return (
|
||||
<TableSearchRecord
|
||||
key={record[0]}
|
||||
recordId={record[0]}
|
||||
// `grid grid-cols-1` stretches the card to fill the fixed-width,
|
||||
// equal-height track; `snap-start` aligns it to the left edge.
|
||||
visibleClassName={tcls(
|
||||
'grid',
|
||||
'grid-cols-1',
|
||||
'shrink-0',
|
||||
'snap-start',
|
||||
cardWidth
|
||||
)}
|
||||
>
|
||||
<RecordCard {...props} record={record} />
|
||||
</TableSearchRecord>
|
||||
);
|
||||
})}
|
||||
</ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -475,7 +475,10 @@ function PageActionWrapper(props: {
|
||||
variant="secondary"
|
||||
label={label ?? shortLabel}
|
||||
aria-label={shortLabel}
|
||||
className="bg-tint-base"
|
||||
// `relative z-20` keeps the button above the breadcrumbs, whose container can sit on
|
||||
// top of it over a page cover. `disabled:bg-tint-base` preserves the background while
|
||||
// busy — the `secondary` variant would otherwise reset it via `disabled:bg-transparent`.
|
||||
className="relative z-20 bg-tint-base disabled:bg-tint-base"
|
||||
onClick={onClick}
|
||||
href={href}
|
||||
target={href ? target : undefined}
|
||||
|
||||
@@ -77,6 +77,7 @@ export async function PageAside(props: {
|
||||
'break-anywhere', // To prevent long words in headings from breaking the layout
|
||||
|
||||
'lg:z-10',
|
||||
'lg:hover:z-12',
|
||||
'layout-default:xl:not-chat-open:pr-0',
|
||||
'layout-default:xl:not-chat-open:pl-8',
|
||||
'layout-default:xl:not-chat-open:flex!',
|
||||
@@ -98,9 +99,16 @@ export async function PageAside(props: {
|
||||
'page-api-block:page-has-outline:min-[96rem]:border-l-0',
|
||||
'page-api-block:page-has-outline:min-[96rem]:pl-8',
|
||||
|
||||
'hydrated:site-background', // Only add a background once the element is positioned correctly to prevent overlapping the page cover
|
||||
// Only add a background once the element is positioned correctly, to prevent
|
||||
// overlapping the page cover. Kept opaque wherever the outline is a toggleable
|
||||
// overlay (below xl in any layout, and xl–3xl in wide layout, where it opens as
|
||||
// a desktop SideSheet), but dropped for the permanent outline column so a bleeding
|
||||
// cards carousel can scroll behind it.
|
||||
'max-xl:hydrated:site-background',
|
||||
'layout-wide:max-3xl:hydrated:site-background',
|
||||
'text-tint',
|
||||
'contrast-more:text-tint-strong'
|
||||
'contrast-more:text-tint-strong',
|
||||
'xl:page-cover-background:text-contrast-cover'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full w-full shrink-0 flex-col overflow-hidden">
|
||||
|
||||
@@ -107,6 +107,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
|
||||
'sidebar-list-line:border-l-2',
|
||||
'border-transparent',
|
||||
'sidebar-list-line:-left-px',
|
||||
'xl:page-cover-background:text-contrast-cover',
|
||||
|
||||
// The method badge no longer has a right margin, so lay the row out with a gap
|
||||
section.tag && ['flex', 'items-baseline', 'gap-2'],
|
||||
|
||||
@@ -93,6 +93,9 @@ export async function PageBody(props: {
|
||||
'py-8',
|
||||
'layout-wide:no-sidebar:lg:max-xl:pb-20', // Add padding to prevent overlap of minimised trademark
|
||||
'@container',
|
||||
// Flex column so the growing content wrapper below fills the page: the footer
|
||||
// navigation settles at the bottom, and a full-page cover shows behind the content.
|
||||
'flex flex-col',
|
||||
CONTENT_STYLE,
|
||||
pageHasToc ? 'page-has-toc' : 'page-no-toc',
|
||||
wideLayout ? 'layout-wide' : 'layout-default'
|
||||
@@ -104,37 +107,44 @@ export async function PageBody(props: {
|
||||
<PageCover as="hero" page={page} cover={page.cover} context={context} />
|
||||
) : null}
|
||||
|
||||
<PageHeader
|
||||
context={context}
|
||||
page={page}
|
||||
ancestors={ancestors}
|
||||
withRSSFeed={contentHasUpdates}
|
||||
hasAPIBlocks={hasAPIBlocks}
|
||||
/>
|
||||
{document && !isNodeEmpty(document) ? (
|
||||
<OptionalSuspense
|
||||
staticRoute={staticRoute}
|
||||
fallback={<DocumentViewSkeleton document={document} blockStyle="" />}
|
||||
>
|
||||
<SuspenseLoadedHint />
|
||||
<div className="contents" data-content-ref-root="">
|
||||
<DocumentView
|
||||
document={document}
|
||||
style="flex flex-col [&>*+*]:mt-5"
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: {
|
||||
...context,
|
||||
page,
|
||||
},
|
||||
withLinkPreviews,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</OptionalSuspense>
|
||||
) : (
|
||||
<PageBodyBlankslate page={page} context={context} />
|
||||
)}
|
||||
{/* Grows to fill the page (so the footer navigation below settles at the bottom) and
|
||||
stays a plain block — this gives the floated, sticky API page-actions a tall
|
||||
containing block to travel within while letting the breadcrumbs wrap around them
|
||||
(see PageHeader). */}
|
||||
<div className="min-w-0 grow">
|
||||
<PageHeader
|
||||
context={context}
|
||||
page={page}
|
||||
ancestors={ancestors}
|
||||
withRSSFeed={contentHasUpdates}
|
||||
hasAPIBlocks={hasAPIBlocks}
|
||||
/>
|
||||
{document && !isNodeEmpty(document) ? (
|
||||
<OptionalSuspense
|
||||
staticRoute={staticRoute}
|
||||
fallback={<DocumentViewSkeleton document={document} blockStyle="" />}
|
||||
>
|
||||
<SuspenseLoadedHint />
|
||||
<div className="contents" data-content-ref-root="">
|
||||
<DocumentView
|
||||
document={document}
|
||||
style="flex flex-col [&>*+*]:mt-5"
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: {
|
||||
...context,
|
||||
page,
|
||||
},
|
||||
withLinkPreviews,
|
||||
isPageBody: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</OptionalSuspense>
|
||||
) : (
|
||||
<PageBodyBlankslate page={page} context={context} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{page.layout.pagination && customization.pagination.enabled ? (
|
||||
<PageFooterNavigation context={context} page={page} />
|
||||
@@ -176,10 +186,7 @@ export async function PageBody(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function LLMsTxtPageDirective(props: {
|
||||
context: GitBookSiteContext;
|
||||
page: RevisionPageDocument;
|
||||
}) {
|
||||
function LLMsTxtPageDirective(props: { context: GitBookSiteContext; page: RevisionPageDocument }) {
|
||||
const { context, page } = props;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type { RevisionPageDocument, RevisionPageDocumentCover } from '@gitbook/api';
|
||||
import {
|
||||
CustomizationHeaderPreset,
|
||||
type RevisionPageDocument,
|
||||
type RevisionPageDocumentCover,
|
||||
} from '@gitbook/api';
|
||||
import type { StaticImageData } from 'next/image';
|
||||
|
||||
import { getImageAttributes } from '@/components/utils';
|
||||
@@ -13,18 +17,38 @@ import { getCoverHeight } from './coverHeight';
|
||||
import defaultPageCoverSVG from './default-page-cover.svg';
|
||||
|
||||
const defaultPageCover = defaultPageCoverSVG as StaticImageData;
|
||||
const DEFAULT_RESPONSIVE_COVER_CUTOFF = '56.25%';
|
||||
|
||||
/**
|
||||
* Cover for the page.
|
||||
*/
|
||||
export async function PageCover(props: {
|
||||
as: 'hero' | 'full';
|
||||
as: 'hero' | 'full' | 'background';
|
||||
page: RevisionPageDocument;
|
||||
cover: RevisionPageDocumentCover;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { as, cover, context } = props;
|
||||
const { as, page, cover, context } = props;
|
||||
const height = getCoverHeight(cover);
|
||||
const mask = page.layout.coverMask === 'radial' ? 'radial' : 'none';
|
||||
|
||||
const initialCoverCutoff = () => {
|
||||
if (!height) {
|
||||
return DEFAULT_RESPONSIVE_COVER_CUTOFF;
|
||||
}
|
||||
|
||||
let total = height;
|
||||
if (context.customization.announcement?.enabled) {
|
||||
total += 68;
|
||||
}
|
||||
if (context.customization.header.preset !== CustomizationHeaderPreset.None) {
|
||||
total += 64;
|
||||
}
|
||||
if (context.visibleSections && context.visibleSections.list.length > 1) {
|
||||
total += 45;
|
||||
}
|
||||
return `${total}px`;
|
||||
};
|
||||
|
||||
const [resolved, resolvedDark] = await Promise.all([
|
||||
cover.ref ? resolveContentRef(cover.ref, context) : null,
|
||||
@@ -79,56 +103,73 @@ export async function PageCover(props: {
|
||||
assert(light, 'Light image should be defined');
|
||||
|
||||
return (
|
||||
<div
|
||||
data-gb-page-cover
|
||||
data-full={String(as === 'full')}
|
||||
className={tcls(
|
||||
'overflow-hidden',
|
||||
// Negative margin to balance the container padding
|
||||
'-mx-4',
|
||||
<>
|
||||
<style>{`:root { --cover-height: ${initialCoverCutoff()}; }`}</style>
|
||||
<div
|
||||
data-gb-page-cover
|
||||
data-cover-text-color={cover.textColor}
|
||||
data-cover-text-color-dark={cover.textColorDark}
|
||||
data-cover-type={as}
|
||||
data-full={String(as === 'full')}
|
||||
className={tcls(
|
||||
'overflow-hidden',
|
||||
// Negative margin to balance the container padding
|
||||
'-mx-4',
|
||||
|
||||
// Full-width cover: extend to edges, disregard TOC where possible
|
||||
as === 'full'
|
||||
? [
|
||||
'sm:-mx-6',
|
||||
'md:-mx-8',
|
||||
'lg:-ml-12',
|
||||
// Full-width cover: extend to edges, disregard TOC where possible
|
||||
as === 'full' || as === 'background'
|
||||
? [
|
||||
'sm:-mx-6',
|
||||
'md:-mx-8',
|
||||
'lg:-ml-12',
|
||||
|
||||
// Extend the full-width cover
|
||||
'layout-default:page-no-toc:lg:-ml-92', // Extend into the left sidebar if there's no TOC...
|
||||
'layout-wide:2xl:-mr-[clamp(2rem,calc((100vw-90rem)/2+2rem),18rem)]', // ...and to the right if there's no outline.
|
||||
'layout-wide:page-no-toc:2xl:-mx-[max(calc((100vw-90rem)/2+2rem),2rem)]', // Span full width if the page content is centered.
|
||||
'layout-wide:has-sidebar:page-no-toc:lg:-ml-[max(calc((100vw-90rem)/2+23rem),23rem)]', // If there's still a sidebar, we have to factor it in too.
|
||||
// Extend the full-width cover
|
||||
'layout-default:page-no-toc:lg:-ml-92', // Extend into the left sidebar if there's no TOC...
|
||||
'layout-wide:2xl:-mr-[clamp(2rem,calc((100vw-90rem)/2+2rem),18rem)]', // ...and to the right if there's no outline.
|
||||
'layout-wide:page-no-toc:2xl:-mx-[max(calc((100vw-90rem)/2+2rem),2rem)]', // Span full width if the page content is centered.
|
||||
'layout-wide:has-sidebar:page-no-toc:lg:-ml-[max(calc((100vw-90rem)/2+23rem),23rem)]', // If there's still a sidebar, we have to factor it in too.
|
||||
|
||||
// Corner rounding: we round once the page is wide enough to have space around the cover.
|
||||
'layout-default:2xl:rounded-corners:rounded-b-xl',
|
||||
'layout-default:2xl:circular-corners:rounded-b-3xl',
|
||||
'layout-wide:3xl:circular-corners:rounded-b-3xl',
|
||||
'layout-wide:3xl:rounded-corners:rounded-b-xl',
|
||||
// Round the bottom left corner once the sidebar is shown next to it
|
||||
'has-sidebar:lg:rounded-corners:rounded-bl-xl',
|
||||
'has-sidebar:lg:circular-corners:rounded-bl-3xl',
|
||||
]
|
||||
: [
|
||||
// Regular cover: size regularly along with other content
|
||||
CONTENT_STYLE,
|
||||
'max-sm:-mx-4',
|
||||
'sm:rounded-corners:rounded-xl',
|
||||
'sm:circular-corners:rounded-3xl',
|
||||
'mb-8',
|
||||
'max-sm:w-screen',
|
||||
'max-sm:-mt-8',
|
||||
]
|
||||
)}
|
||||
>
|
||||
<PageCoverImage
|
||||
imgs={{
|
||||
light,
|
||||
dark,
|
||||
}}
|
||||
y={cover.yPos}
|
||||
height={height}
|
||||
/>
|
||||
</div>
|
||||
// Corner rounding: we round once the page is wide enough to have space around the cover.
|
||||
'layout-default:2xl:rounded-corners:rounded-b-xl',
|
||||
'layout-default:2xl:circular-corners:rounded-b-3xl',
|
||||
'layout-wide:3xl:circular-corners:rounded-b-3xl',
|
||||
'layout-wide:3xl:rounded-corners:rounded-b-xl',
|
||||
// Round the bottom left corner once the sidebar is shown next to it
|
||||
'has-sidebar:lg:rounded-corners:rounded-bl-xl',
|
||||
'has-sidebar:lg:circular-corners:rounded-bl-3xl',
|
||||
]
|
||||
: null,
|
||||
|
||||
as === 'hero'
|
||||
? [
|
||||
// Regular cover: size regularly along with other content
|
||||
CONTENT_STYLE,
|
||||
'max-sm:-mx-4',
|
||||
'sm:rounded-corners:rounded-xl',
|
||||
'sm:circular-corners:rounded-3xl',
|
||||
'mb-8',
|
||||
'max-sm:w-screen',
|
||||
'max-sm:-mt-8',
|
||||
]
|
||||
: null,
|
||||
|
||||
as === 'background'
|
||||
? [
|
||||
'-z-1 absolute inset-x-0 contrast-more:opacity-5 *:contrast-more:blur-md',
|
||||
]
|
||||
: null
|
||||
)}
|
||||
>
|
||||
<PageCoverImage
|
||||
imgs={{
|
||||
light,
|
||||
dark,
|
||||
}}
|
||||
y={cover.yPos}
|
||||
height={height}
|
||||
mask={mask}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,10 +24,11 @@ interface PageCoverImageProps {
|
||||
y: number;
|
||||
// Only if the `height` was customized by the user (and thus defined), we use it to set the cover's height and skip the default behaviour of fixed aspect-ratio.
|
||||
height: number | undefined;
|
||||
mask?: 'none' | 'radial';
|
||||
}
|
||||
|
||||
export function PageCoverImage(props: PageCoverImageProps) {
|
||||
const { imgs, y, height } = props;
|
||||
const { imgs, y, height, mask } = props;
|
||||
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -53,6 +54,11 @@ export function PageCoverImage(props: PageCoverImageProps) {
|
||||
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${objectPositionY}%`,
|
||||
height, // if no height is passed, no height will be set.
|
||||
maskComposite: 'intersect',
|
||||
maskImage:
|
||||
mask === 'radial'
|
||||
? 'radial-gradient(200% 200% at 50% -100%, black 70%, rgba(0,0,0,0.85) 77.5%, rgba(0,0,0,0.6) 85%, rgba(0,0,0,0.1) 96.25%, transparent 100%), linear-gradient(to left, black 60%, rgba(0,0,0,0.8) 75%, rgba(0,0,0,0.1) 95%, transparent 100%), linear-gradient(to right, black 60%, rgba(0,0,0,0.8) 75%, rgba(0,0,0,0.1) 95%, transparent 100%)'
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
{imgs.dark && (
|
||||
@@ -69,6 +75,11 @@ export function PageCoverImage(props: PageCoverImageProps) {
|
||||
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${objectPositionY}%`,
|
||||
height, // if no height is passed, no height will be set.
|
||||
maskComposite: 'intersect',
|
||||
maskImage:
|
||||
mask === 'radial'
|
||||
? 'radial-gradient(200% 200% at 50% -100%, black 70%, rgba(0,0,0,0.85) 77.5%, rgba(0,0,0,0.6) 85%, rgba(0,0,0,0.1) 96.25%, transparent 100%), linear-gradient(to left, black 60%, rgba(0,0,0,0.8) 75%, rgba(0,0,0,0.1) 95%, transparent 100%), linear-gradient(to right, black 60%, rgba(0,0,0,0.8) 75%, rgba(0,0,0,0.1) 95%, transparent 100%)'
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -148,11 +148,14 @@ export async function PageHeader(props: {
|
||||
className={tcls(
|
||||
'float-right ml-4 flex gap-2',
|
||||
showBreadcrumbs ? '-mb-1 -mt-1.5' : '-mt-3 xs:mt-2',
|
||||
// On desktop API pages (where this <div> is rendered as a sibling of <header>, see
|
||||
// below) keep the actions pinned below the site header while scrolling long
|
||||
// operations. The offset tracks the header height (banner, cover…) via the same
|
||||
// --toc-top-offset the outline and code samples use. Hidden while the outline drawer
|
||||
// is open (drawer widths only) so it doesn't overlap the sheet.
|
||||
// On desktop API pages these actions are pulled out of <header> (rendered as its
|
||||
// preceding sibling, see below) so their sticky containing block is the tall,
|
||||
// growing content wrapper in <main> — a plain block — rather than the short header.
|
||||
// There they keep the base `float-right`, so the breadcrumbs wrap around them at any
|
||||
// width, while pinning below the site header when scrolling long operations. The
|
||||
// offset tracks the header height (banner, cover…) via the same --toc-top-offset the
|
||||
// outline and code samples use. Hidden while the outline drawer is open (drawer
|
||||
// widths only) so it doesn't overlap the sheet.
|
||||
hasAPIBlocks && [
|
||||
'page-api-block:lg:sticky',
|
||||
'page-api-block:lg:top-[calc(var(--toc-top-offset,4rem)+1rem)]',
|
||||
@@ -185,7 +188,7 @@ export async function PageHeader(props: {
|
||||
// content spans the full width with no navigation column, so the crumbs sit stranded.
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className="layout-wide:page-no-toc:hidden text-tint text-xs leading-relaxed"
|
||||
className="layout-wide:page-no-toc:hidden page-cover-background:text-contrast-cover text-tint text-xs leading-relaxed page-cover-background:opacity-9"
|
||||
>
|
||||
<ol className="inline">
|
||||
{contextCrumbs.map((crumb, index) => (
|
||||
@@ -238,7 +241,8 @@ export async function PageHeader(props: {
|
||||
'grow',
|
||||
'text-pretty',
|
||||
'clear-right',
|
||||
'xs:clear-none'
|
||||
'xs:clear-none',
|
||||
'page-cover-background:text-contrast-cover'
|
||||
)}
|
||||
>
|
||||
<PageIcon page={page} style={['text-tint-subtle ', 'shrink-0']} />
|
||||
@@ -246,7 +250,15 @@ export async function PageHeader(props: {
|
||||
</h1>
|
||||
) : null}
|
||||
{page.description && page.layout.description ? (
|
||||
<p className={tcls(CONTENT_STYLE_REDUCED, 'text-lg', 'text-tint', 'clear-both')}>
|
||||
<p
|
||||
className={tcls(
|
||||
CONTENT_STYLE_REDUCED,
|
||||
'text-lg',
|
||||
'page-cover-background:text-contrast-cover',
|
||||
'text-tint contrast-more:text-tint-strong',
|
||||
'clear-both'
|
||||
)}
|
||||
>
|
||||
{page.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CustomizationDefaultThemeMode,
|
||||
CustomizationSidebarBackgroundStyle,
|
||||
CustomizationSidebarListStyle,
|
||||
CustomizationTheme,
|
||||
type CustomizationThemedColor,
|
||||
type CustomizationTint,
|
||||
type SiteCustomizationSettings,
|
||||
@@ -79,6 +80,13 @@ export async function CustomizationRootLayout(props: {
|
||||
const tintColor = getTintColor(customization);
|
||||
const mixColor = getTintMixColor(customization.styling.primaryColor, tintColor);
|
||||
const sidebarStyles = getSidebarStyles(customization);
|
||||
const theme = 'theme' in customization.styling ? customization.styling.theme : undefined;
|
||||
// Which scale step the theme renders as the page background — the step an exact light/dark tint
|
||||
// anchors to. `muted` uses tint-subtle (step 2), other themes tint-base (step 1). `bold` is
|
||||
// intentionally two-tone and already uses the tint for the header, so it opts out entirely
|
||||
// (undefined) and keeps a neutral page background.
|
||||
const tintBaseStep =
|
||||
theme === CustomizationTheme.Bold ? undefined : theme === CustomizationTheme.Muted ? 2 : 1;
|
||||
const { infoColor, successColor, warningColor, dangerColor } = getSemanticColors(customization);
|
||||
const fontData = getFontData(customization.styling.font, 'content');
|
||||
// Temporarily add a if here while the cache is being warmed up.
|
||||
@@ -158,7 +166,7 @@ export async function CustomizationRootLayout(props: {
|
||||
>{`
|
||||
:root, .light, .dark [data-color-scheme$="light"], .dark [data-follow-color-scheme="true"]:has([data-color-scheme$="light"]) {
|
||||
${generateColorVariable('primary', customization.styling.primaryColor.light)}
|
||||
${generateColorVariable('tint', tintColor ? tintColor.light : DEFAULT_TINT_COLOR, { mix: mixColor && { color: mixColor.color.light, ratio: mixColor.ratio.light } })}
|
||||
${generateColorVariable('tint', tintColor ? tintColor.light : DEFAULT_TINT_COLOR, { baseStep: tintBaseStep, mix: mixColor && { color: mixColor.color.light, ratio: mixColor.ratio.light } })}
|
||||
${generateColorVariable('neutral', DEFAULT_TINT_COLOR)}
|
||||
|
||||
--header-background: ${
|
||||
@@ -185,7 +193,7 @@ export async function CustomizationRootLayout(props: {
|
||||
|
||||
.dark, :root:not(.dark) [data-color-scheme^="dark"], :root:not(.dark) [data-follow-color-scheme="true"]:has([data-color-scheme^="dark"]) {
|
||||
${generateColorVariable('primary', customization.styling.primaryColor.dark, { darkMode: true })}
|
||||
${generateColorVariable('tint', tintColor ? tintColor.dark : DEFAULT_TINT_COLOR, { darkMode: true, mix: mixColor && { color: mixColor?.color.dark, ratio: mixColor.ratio.dark } })}
|
||||
${generateColorVariable('tint', tintColor ? tintColor.dark : DEFAULT_TINT_COLOR, { darkMode: true, baseStep: tintBaseStep, mix: mixColor && { color: mixColor?.color.dark, ratio: mixColor.ratio.dark } })}
|
||||
${generateColorVariable('neutral', DEFAULT_TINT_COLOR, { darkMode: true })}
|
||||
|
||||
--header-background: ${hexToRgb(customization.header.backgroundColor?.dark ?? tintColor?.dark ?? customization.styling.primaryColor.dark)};
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
@config '../../../tailwind.config.ts';
|
||||
|
||||
@import './prose.css';
|
||||
|
||||
@import "./prose.css";
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
@@ -14,37 +13,37 @@
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
@utility hide-scrollbar {
|
||||
/* Hide scrollbar by default */
|
||||
scrollbar-color: transparent transparent;
|
||||
transition: scrollbar-color 0.2s ease-in-out;
|
||||
/* Hide scrollbar by default */
|
||||
scrollbar-color: transparent transparent;
|
||||
transition: scrollbar-color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
/* Show scrollbar on hover */
|
||||
scrollbar-color: unset;
|
||||
}
|
||||
&:hover {
|
||||
/* Show scrollbar on hover */
|
||||
scrollbar-color: unset;
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
/* Always show scrollbar in high contrast mode */
|
||||
scrollbar-color: unset;
|
||||
}
|
||||
@media (prefers-contrast: more) {
|
||||
/* Always show scrollbar in high contrast mode */
|
||||
scrollbar-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -55,137 +54,147 @@
|
||||
wrapper from React, so let pointer events pass through it for these tooltips only.
|
||||
*/
|
||||
[data-radix-popper-content-wrapper]:has([data-non-interactive]) {
|
||||
pointer-events: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@utility linear-mask-gradient {
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 96px, rgba(0, 0, 0, 0));
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 96px, rgba(0, 0, 0, 0));
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 1) 96px,
|
||||
rgba(0, 0, 0, 0)
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 1) 96px,
|
||||
rgba(0, 0, 0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
@utility linear-mask-util {
|
||||
mask-image: linear-gradient(to bottom, white, white);
|
||||
mask-image: linear-gradient(to bottom, white, white);
|
||||
mask-image: linear-gradient(to bottom, white, white);
|
||||
mask-image: linear-gradient(to bottom, white, white);
|
||||
}
|
||||
|
||||
@utility grid-area-1-1 {
|
||||
grid-area: 1 / 1;
|
||||
grid-area: 1 / 1;
|
||||
grid-area: 1 / 1;
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
@utility gutter-stable {
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@utility triangle {
|
||||
position: relative;
|
||||
background-color: orange;
|
||||
text-align: left;
|
||||
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
position: relative;
|
||||
background-color: orange;
|
||||
text-align: left;
|
||||
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
&:before {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
&:before {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
&:after {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
|
||||
&:before {
|
||||
transform: rotate(-135deg) skewX(-45deg) scale(1.414, 0.707) translate(0, -50%);
|
||||
}
|
||||
&:after {
|
||||
transform: rotate(135deg) skewY(-45deg) scale(0.707, 1.414) translate(50%);
|
||||
}
|
||||
position: relative;
|
||||
background-color: orange;
|
||||
text-align: left;
|
||||
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
}
|
||||
&:after {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
&:before {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
&:after {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
&:before {
|
||||
transform: rotate(-135deg) skewX(-45deg) scale(1.414, 0.707) translate(0, -50%);
|
||||
}
|
||||
&:after {
|
||||
transform: rotate(135deg) skewY(-45deg) scale(0.707, 1.414) translate(50%);
|
||||
}
|
||||
&:before {
|
||||
transform: rotate(-135deg) skewX(-45deg) scale(1.414, 0.707)
|
||||
translate(0, -50%);
|
||||
}
|
||||
&:after {
|
||||
transform: rotate(135deg) skewY(-45deg) scale(0.707, 1.414) translate(50%);
|
||||
}
|
||||
position: relative;
|
||||
background-color: orange;
|
||||
text-align: left;
|
||||
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
&:before {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
&:after {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
|
||||
&:before {
|
||||
transform: rotate(-135deg) skewX(-45deg) scale(1.414, 0.707)
|
||||
translate(0, -50%);
|
||||
}
|
||||
&:after {
|
||||
transform: rotate(135deg) skewY(-45deg) scale(0.707, 1.414) translate(50%);
|
||||
}
|
||||
}
|
||||
|
||||
@utility break-anywhere {
|
||||
word-break: break-word;
|
||||
@supports (overflow-wrap: anywhere) {
|
||||
word-break: break-word;
|
||||
@supports (overflow-wrap: anywhere) {
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
word-break: break-word;
|
||||
@supports (overflow-wrap: anywhere) {
|
||||
word-break: break-word;
|
||||
@supports (overflow-wrap: anywhere) {
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-gradient-primary {
|
||||
@apply bg-linear-to-bl from-primary-4 to-tint-base to-60% bg-fixed;
|
||||
@apply bg-linear-to-bl from-primary-4 to-tint-base to-60% bg-fixed;
|
||||
@apply bg-linear-to-bl from-primary-4 to-tint-base to-60% bg-fixed;
|
||||
@apply bg-linear-to-bl from-primary-4 to-tint-base to-60% bg-fixed;
|
||||
}
|
||||
|
||||
@utility bg-gradient-tint {
|
||||
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
|
||||
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
|
||||
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
|
||||
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
|
||||
}
|
||||
|
||||
@utility site-background {
|
||||
@apply [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle;
|
||||
@apply bg-tint-base;
|
||||
@apply theme-muted:bg-tint-subtle;
|
||||
@apply [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle;
|
||||
@apply bg-tint-base;
|
||||
@apply theme-muted:bg-tint-subtle;
|
||||
|
||||
@apply theme-gradient:bg-gradient-primary;
|
||||
@apply theme-gradient-tint:bg-gradient-tint;
|
||||
@apply theme-gradient:bg-gradient-primary;
|
||||
@apply theme-gradient-tint:bg-gradient-tint;
|
||||
}
|
||||
|
||||
@utility elevate-link {
|
||||
& a[href]:not(.link-overlay),
|
||||
& [data-annotation] {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
& a[href]:not(.link-overlay),
|
||||
& [data-annotation] {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -197,141 +206,190 @@
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
@apply leading-relaxed;
|
||||
interpolate-size: allow-keywords; /* Opt-in for modern browsers to interpolate "auto" values in transitions/animations. */
|
||||
overflow-x: hidden; /* We never want horizontal scroll of the whole page, it looks buggy and we should never have overflow anyway */
|
||||
--ai-chat-width: 24rem; /* Default AI chat panel width (= AI_CHAT_DEFAULT_WIDTH 384px); overridden client-side from local storage. */
|
||||
}
|
||||
|
||||
/* Modern browsers with `scrollbar-*` support */
|
||||
@supports (scrollbar-color: auto) {
|
||||
* {
|
||||
scrollbar-color: theme("colors.tint.7") transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
:root {
|
||||
@apply leading-relaxed;
|
||||
interpolate-size: allow-keywords; /* Opt-in for modern browsers to interpolate "auto" values in transitions/animations. */
|
||||
overflow-x: hidden; /* We never want horizontal scroll of the whole page, it looks buggy and we should never have overflow anyway */
|
||||
--ai-chat-width: 24rem; /* Default AI chat panel width (= AI_CHAT_DEFAULT_WIDTH 384px); overridden client-side from local storage. */
|
||||
--cover-height: 0px;
|
||||
--cover-text-top: rgb(var(--tint-12));
|
||||
--cover-text-bottom: rgb(var(--tint-12));
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
* {
|
||||
scrollbar-color: theme("colors.tint.11") transparent;
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Otherwise, use `::-webkit-scrollbar-*` pseudo-elements */
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
::-webkit-scrollbar {
|
||||
background: transparent;
|
||||
max-width: 8px;
|
||||
max-height: 6px;
|
||||
}
|
||||
:root:not(.dark):has(
|
||||
[data-gb-page-cover][data-cover-type="background"][data-cover-text-color="white"]
|
||||
),
|
||||
:root.dark:has(
|
||||
[data-gb-page-cover][data-cover-type="background"][data-cover-text-color-dark="black"]
|
||||
) {
|
||||
--cover-text-top: rgb(var(--contrast-tint-12));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: theme("colors.tint.7");
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: theme("colors.tint.8");
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: theme("colors.tint.11");
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: theme("colors.tint.12");
|
||||
}
|
||||
}
|
||||
/* Modern browsers with `scrollbar-*` support */
|
||||
@supports (scrollbar-color: auto) {
|
||||
* {
|
||||
scrollbar-color: theme("colors.tint.7") transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
@apply text-tint-strong antialiased;
|
||||
}
|
||||
html {
|
||||
@media (prefers-contrast: more) {
|
||||
* {
|
||||
scrollbar-color: theme("colors.tint.11") transparent;
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
@apply tracking-[-0.025em] text-tint-strong text-balance;
|
||||
}
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply tracking-[-0.0125em] text-tint-strong;
|
||||
}
|
||||
|
||||
/* Otherwise, use `::-webkit-scrollbar-*` pseudo-elements */
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
::-webkit-scrollbar {
|
||||
background: transparent;
|
||||
max-width: 8px;
|
||||
max-height: 6px;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
@apply focus-visible:outline-2 focus-visible:outline-primary;
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: theme("colors.tint.7");
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: theme("colors.tint.8");
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
/* Don't apply antialiased to `code` and `pre` elements */
|
||||
@apply subpixel-antialiased;
|
||||
@media (prefers-contrast: more) {
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: theme("colors.tint.11");
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: theme("colors.tint.12");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-tint-strong antialiased;
|
||||
}
|
||||
html {
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
h1 {
|
||||
@apply tracking-[-0.025em] text-tint-strong text-balance;
|
||||
}
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply tracking-[-0.0125em] text-tint-strong;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
@apply focus-visible:outline-2 focus-visible:outline-primary;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
/* Don't apply antialiased to `code` and `pre` elements */
|
||||
@apply subpixel-antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@utility contrast-cover {
|
||||
background-clip: border-box;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--cover-text-top) 0,
|
||||
var(--cover-text-top) var(--cover-height),
|
||||
var(--cover-text-bottom) var(--cover-height),
|
||||
var(--cover-text-bottom) 100vh
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100vh;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
@utility text-contrast-cover {
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--cover-text-top) 0,
|
||||
var(--cover-text-top) var(--cover-height),
|
||||
var(--cover-text-bottom) var(--cover-height),
|
||||
var(--cover-text-bottom) 100vh
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100vh;
|
||||
background-attachment: fixed;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
color: var(--cover-text-bottom);
|
||||
-webkit-text-fill-color: var(--cover-text-bottom);
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: light;
|
||||
color-scheme: light;
|
||||
|
||||
/** Ensure PDF export and print correctly displays the background colors */
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
/** Ensure PDF export and print correctly displays the background colors */
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
html.dark {
|
||||
color-scheme: dark light;
|
||||
color-scheme: dark light;
|
||||
}
|
||||
|
||||
/** While the AI chat panel is being resized, suppress transitions */
|
||||
html[data-ai-chat-resizing="true"] {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
html[data-ai-chat-resizing="true"] * {
|
||||
transition-duration: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
}
|
||||
|
||||
html.announcement-hidden [data-gb-announcement-banner] {
|
||||
@apply hidden;
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
html.font-Lato {
|
||||
/* Lato's default ligatures impact readability, so we turn them off */
|
||||
font-variant-ligatures: no-common-ligatures;
|
||||
/* Lato's default ligatures impact readability, so we turn them off */
|
||||
font-variant-ligatures: no-common-ligatures;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
/* Shiki themes can define font styling (style, weight, decoration) via CSS variables.
|
||||
* These variables are prefixed with --shiki-{mode}-{property} and allow themes to customize
|
||||
* typography beyond just colors. We apply them here so code blocks respect theme font styling. */
|
||||
.shiki, .shiki span {
|
||||
font-style: var(--shiki-light-font-style) !important;
|
||||
font-weight: var(--shiki-light-font-weight) !important;
|
||||
text-decoration: var(--shiki-light-text-decoration) !important;
|
||||
.shiki,
|
||||
.shiki span {
|
||||
font-style: var(--shiki-light-font-style) !important;
|
||||
font-weight: var(--shiki-light-font-weight) !important;
|
||||
text-decoration: var(--shiki-light-text-decoration) !important;
|
||||
}
|
||||
|
||||
html.dark .shiki,
|
||||
@@ -342,29 +400,29 @@ html.dark .shiki span {
|
||||
}
|
||||
|
||||
.highlight-line {
|
||||
@apply min-w-min text-tint-strong table-row bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base relative hover:invert-5 hover:z-1 rounded-sm;
|
||||
@apply only:hover:ring-transparent;
|
||||
@apply [counter-increment:line];
|
||||
@apply min-w-min text-tint-strong table-row bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base relative hover:invert-5 hover:z-1 rounded-sm;
|
||||
@apply only:hover:ring-transparent;
|
||||
@apply [counter-increment:line];
|
||||
|
||||
&.highlighted {
|
||||
/* Use `invert-` to get a dynamic color that contrasts with the background, regardless of the codeblock's theme. */
|
||||
@apply bg-tint-base invert-10 hover:invert-15;
|
||||
@apply first:rounded-t-md first:*:mt-1;
|
||||
@apply last:rounded-b-md last:*:mb-1;
|
||||
@apply rounded-none;
|
||||
}
|
||||
&.highlighted {
|
||||
/* Use `invert-` to get a dynamic color that contrasts with the background, regardless of the codeblock's theme. */
|
||||
@apply bg-tint-base invert-10 hover:invert-15;
|
||||
@apply first:rounded-t-md first:*:mt-1;
|
||||
@apply last:rounded-b-md last:*:mb-1;
|
||||
@apply rounded-none;
|
||||
}
|
||||
|
||||
&:not(.highlighted) + .highlighted {
|
||||
@apply rounded-t-md *:mt-1;
|
||||
}
|
||||
&:not(.highlighted) + .highlighted {
|
||||
@apply rounded-t-md *:mt-1;
|
||||
}
|
||||
|
||||
&.highlighted:has(+ :not(.highlighted)) {
|
||||
@apply rounded-b-md *:mb-1;
|
||||
}
|
||||
&.highlighted:has(+ :not(.highlighted)) {
|
||||
@apply rounded-b-md *:mb-1;
|
||||
}
|
||||
|
||||
&:not(.highlighted) + .highlighted:has(+ :not(.highlighted)) {
|
||||
@apply rounded-md;
|
||||
}
|
||||
&:not(.highlighted) + .highlighted:has(+ :not(.highlighted)) {
|
||||
@apply rounded-md;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -381,70 +439,70 @@ html.dark .shiki span {
|
||||
* otherwise muddy the tint.
|
||||
*/
|
||||
.highlight-line.diff-added {
|
||||
background-color: #e1f8ec !important; /* gbx --green-3 (light) */
|
||||
@apply rounded-md;
|
||||
background-color: #e1f8ec !important; /* gbx --green-3 (light) */
|
||||
@apply rounded-md;
|
||||
}
|
||||
.highlight-line.diff-deleted {
|
||||
background-color: #ffeae6 !important; /* gbx --red-3 (light) */
|
||||
@apply rounded-md;
|
||||
background-color: #ffeae6 !important; /* gbx --red-3 (light) */
|
||||
@apply rounded-md;
|
||||
}
|
||||
.highlight-line.diff-added.highlighted {
|
||||
background-color: #baebd3 !important; /* gbx --green-5 (light) */
|
||||
filter: none !important;
|
||||
background-color: #baebd3 !important; /* gbx --green-5 (light) */
|
||||
filter: none !important;
|
||||
}
|
||||
.highlight-line.diff-deleted.highlighted {
|
||||
background-color: #ffcac0 !important; /* gbx --red-5 (light) */
|
||||
filter: none !important;
|
||||
background-color: #ffcac0 !important; /* gbx --red-5 (light) */
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
html.dark .highlight-line.diff-added {
|
||||
background-color: #113023 !important; /* gbx --green-3 (dark) */
|
||||
background-color: #113023 !important; /* gbx --green-3 (dark) */
|
||||
}
|
||||
html.dark .highlight-line.diff-deleted {
|
||||
background-color: #411510 !important; /* gbx --red-3 (dark) */
|
||||
background-color: #411510 !important; /* gbx --red-3 (dark) */
|
||||
}
|
||||
html.dark .highlight-line.diff-added.highlighted {
|
||||
background-color: #064b34 !important; /* gbx --green-5 (dark) */
|
||||
filter: none !important;
|
||||
background-color: #064b34 !important; /* gbx --green-5 (dark) */
|
||||
filter: none !important;
|
||||
}
|
||||
html.dark .highlight-line.diff-deleted.highlighted {
|
||||
background-color: #67100a !important; /* gbx --red-5 (dark) */
|
||||
filter: none !important;
|
||||
background-color: #67100a !important; /* gbx --red-5 (dark) */
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
.highlight-line-number {
|
||||
@apply table-cell whitespace-nowrap w-0 text-sm text-tint pl-4 pr-2 text-right bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base sticky -left-2;
|
||||
@apply before:content-[counter(line)] not-contrast-more:before:opacity-6;
|
||||
@apply table-cell whitespace-nowrap w-0 text-sm text-tint pl-4 pr-2 text-right bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base sticky -left-2;
|
||||
@apply before:content-[counter(line)] not-contrast-more:before:opacity-6;
|
||||
|
||||
.highlighted & {
|
||||
@apply bg-tint-base
|
||||
}
|
||||
.highlighted & {
|
||||
@apply bg-tint-base;
|
||||
}
|
||||
}
|
||||
|
||||
/* Diff line gutter (line numbers) — same tint as the line itself. */
|
||||
.diff-added > .highlight-line-number {
|
||||
background-color: #e1f8ec !important;
|
||||
background-color: #e1f8ec !important;
|
||||
}
|
||||
.diff-deleted > .highlight-line-number {
|
||||
background-color: #ffeae6 !important;
|
||||
background-color: #ffeae6 !important;
|
||||
}
|
||||
.diff-added.highlighted > .highlight-line-number {
|
||||
background-color: #baebd3 !important;
|
||||
background-color: #baebd3 !important;
|
||||
}
|
||||
.diff-deleted.highlighted > .highlight-line-number {
|
||||
background-color: #ffcac0 !important;
|
||||
background-color: #ffcac0 !important;
|
||||
}
|
||||
html.dark .diff-added > .highlight-line-number {
|
||||
background-color: #113023 !important;
|
||||
background-color: #113023 !important;
|
||||
}
|
||||
html.dark .diff-deleted > .highlight-line-number {
|
||||
background-color: #411510 !important;
|
||||
background-color: #411510 !important;
|
||||
}
|
||||
html.dark .diff-added.highlighted > .highlight-line-number {
|
||||
background-color: #064b34 !important;
|
||||
background-color: #064b34 !important;
|
||||
}
|
||||
html.dark .diff-deleted.highlighted > .highlight-line-number {
|
||||
background-color: #67100a !important;
|
||||
background-color: #67100a !important;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -455,32 +513,31 @@ html.dark .diff-deleted.highlighted > .highlight-line-number {
|
||||
*/
|
||||
.highlight-line.diff-added .highlight-line-content::before,
|
||||
.highlight-line.diff-deleted .highlight-line-content::before {
|
||||
display: inline-block;
|
||||
width: 1ch;
|
||||
margin-right: 0.5ch;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
width: 1ch;
|
||||
margin-right: 0.5ch;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
.highlight-line.diff-added .highlight-line-content::before {
|
||||
content: "+";
|
||||
color: #247758; /* gbx --green-11 light */
|
||||
content: "+";
|
||||
color: #247758; /* gbx --green-11 light */
|
||||
}
|
||||
.highlight-line.diff-deleted .highlight-line-content::before {
|
||||
content: "\2212"; /* U+2212 minus sign — visually clearer than a hyphen */
|
||||
color: #de0000; /* gbx --red-11 light */
|
||||
content: "\2212"; /* U+2212 minus sign — visually clearer than a hyphen */
|
||||
color: #de0000; /* gbx --red-11 light */
|
||||
}
|
||||
html.dark .highlight-line.diff-added .highlight-line-content::before {
|
||||
color: #6ecea5; /* gbx --green-11 dark */
|
||||
color: #6ecea5; /* gbx --green-11 dark */
|
||||
}
|
||||
html.dark .highlight-line.diff-deleted .highlight-line-content::before {
|
||||
color: #ff9080; /* gbx --red-11 dark */
|
||||
color: #ff9080; /* gbx --red-11 dark */
|
||||
}
|
||||
|
||||
|
||||
.highlight-line-content {
|
||||
@apply table-cell text-sm px-4;
|
||||
@apply table-cell text-sm px-4;
|
||||
|
||||
.highlight-line-number + & {
|
||||
@apply pl-2;
|
||||
}
|
||||
.highlight-line-number + & {
|
||||
@apply pl-2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function PageClientLayout({
|
||||
useRegisterPageMetadata({ pageMetaLinks });
|
||||
|
||||
useStripFallbackQueryParam();
|
||||
useSetCoverHeight();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -60,3 +61,78 @@ function useRegisterPageMetadata(metadata: {
|
||||
currentPageMetadataStore.setState({ metaLinks: pageMetaLinks });
|
||||
}, [pageMetaLinks]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose the visible bottom edge of the page cover as a viewport-relative CSS variable.
|
||||
*/
|
||||
function useSetCoverHeight() {
|
||||
React.useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
let animationFrame: number | null = null;
|
||||
|
||||
const updateCoverHeight = () => {
|
||||
const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]');
|
||||
const isBackgroundCover = pageCover?.dataset.coverType === 'background';
|
||||
|
||||
if (!isBackgroundCover) {
|
||||
root.style.setProperty('--cover-height', '0px');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pageCover) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bottom = pageCover.getBoundingClientRect().bottom;
|
||||
const height = Math.max(Math.min(bottom, window.innerHeight), 0);
|
||||
|
||||
root.style.setProperty('--cover-height', `${height}px`);
|
||||
};
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
if (animationFrame !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
animationFrame = null;
|
||||
updateCoverHeight();
|
||||
});
|
||||
};
|
||||
|
||||
scheduleUpdate();
|
||||
|
||||
window.addEventListener('scroll', scheduleUpdate, { passive: true });
|
||||
window.addEventListener('resize', scheduleUpdate, { passive: true });
|
||||
|
||||
const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]');
|
||||
const resizeObserver =
|
||||
pageCover && typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(() => {
|
||||
scheduleUpdate();
|
||||
})
|
||||
: null;
|
||||
|
||||
if (pageCover && resizeObserver) {
|
||||
resizeObserver.observe(pageCover);
|
||||
}
|
||||
|
||||
// Dismissing the announcement banner only toggles a class on <html> (see
|
||||
// dismissAnnouncement) — no scroll/resize event and no cover resize — yet it shifts the
|
||||
// cover up. Watch <html> class changes so the cover height is recomputed in that case too.
|
||||
const classObserver =
|
||||
typeof MutationObserver !== 'undefined' ? new MutationObserver(scheduleUpdate) : null;
|
||||
classObserver?.observe(root, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
return () => {
|
||||
if (animationFrame !== null) {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
|
||||
resizeObserver?.disconnect();
|
||||
classObserver?.disconnect();
|
||||
window.removeEventListener('scroll', scheduleUpdate);
|
||||
window.removeEventListener('resize', scheduleUpdate);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -81,9 +81,18 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
|
||||
<>
|
||||
{/* Using `contents` makes the children of this div according to its parent — which keeps them in a single flex row with the TOC by default.
|
||||
If there's a page cover, we use `flex flex-col` to lay out the PageCover above the PageBody + PageAside instead. */}
|
||||
<div className={withFullPageCover && page.cover ? 'flex grow flex-col' : 'contents'}>
|
||||
<div
|
||||
className={
|
||||
withFullPageCover && page.cover ? 'relative flex grow flex-col' : 'contents'
|
||||
}
|
||||
>
|
||||
{withFullPageCover && page.cover ? (
|
||||
<PageCover as="full" page={page} cover={page.cover} context={context} />
|
||||
<PageCover
|
||||
as={page.layout.coverSize === 'background' ? 'background' : 'full'}
|
||||
page={page}
|
||||
cover={page.cover}
|
||||
context={context}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
@@ -272,7 +281,7 @@ export async function getSitePageData(props: SitePageProps) {
|
||||
const withFullPageCover = !!(
|
||||
page.cover &&
|
||||
page.layout.cover &&
|
||||
page.layout.coverSize === 'full'
|
||||
(page.layout.coverSize === 'full' || page.layout.coverSize === 'background')
|
||||
);
|
||||
const withPageFeedback = customization.feedback.enabled;
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export const variantClasses = {
|
||||
],
|
||||
secondary: [
|
||||
'bg-tint',
|
||||
'depth-flat:bg-transparent',
|
||||
'depth-flat:bg-tint-base',
|
||||
'text-tint',
|
||||
'hover:bg-tint-hover',
|
||||
'hover:not-disabled:depth-flat:bg-tint-hover',
|
||||
|
||||
@@ -9,8 +9,10 @@ export function getEmojiForCode(code: string): string {
|
||||
}
|
||||
|
||||
code = code.toLowerCase();
|
||||
const fullCode = emojiCodepoints[code] ?? code;
|
||||
|
||||
const fullCode =
|
||||
// use hasOwn to prevent codes like "constructor" or "prototype" from being resolved to the emoji codepoints object prototype
|
||||
(Object.hasOwn(emojiCodepoints, code) ? emojiCodepoints[code] : undefined) ?? code;
|
||||
const codePoints = fullCode.split('-').map((elt) => Number.parseInt(elt, 16));
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createOAuthProtectedResourceUnauthResponse,
|
||||
handleUnauthedOAuthProtectedResourceRequest,
|
||||
isOAuthProtectedResourceMetadataRequest,
|
||||
isOAuthProtectedResourceMetadataRequestForAuthEndpoint,
|
||||
isOAuthProtectedResourceRequest,
|
||||
} from './oauth-protected';
|
||||
|
||||
@@ -250,4 +251,32 @@ describe('OAuth protected resources flow', () => {
|
||||
expect(isOAuthProtectedResourceMetadataRequest(url)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOAuthProtectedResourceMetadataRequestForAuthEndpoint', () => {
|
||||
it.each([
|
||||
{
|
||||
scenario: 'matches metadata doc for the authenticated resource',
|
||||
input: 'https://docs.acme.org/.well-known/oauth-protected-resource/~gitbook/mcp/auth',
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
scenario: 'does not match metadata doc for the public base resource',
|
||||
input: 'https://docs.acme.org/.well-known/oauth-protected-resource/~gitbook/mcp',
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
scenario: 'does not match the resource itself',
|
||||
input: 'https://docs.acme.org/~gitbook/mcp/auth',
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
scenario: 'does not match a non-protected path',
|
||||
input: 'https://docs.acme.org/.well-known/oauth-protected-resource/~gitbook/other',
|
||||
expected: false,
|
||||
},
|
||||
])('$scenario', ({ input, expected }) => {
|
||||
const url = new URL(input);
|
||||
expect(isOAuthProtectedResourceMetadataRequestForAuthEndpoint(url)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,13 @@ type OAuthProtectedResource = {
|
||||
endpoint: string;
|
||||
/** Authentication realm for this resource */
|
||||
realm?: string;
|
||||
/**
|
||||
* Whether this resource requires authentication regardless of visitor auth.
|
||||
* The base `~gitbook/mcp` endpoint is only protected when the site enforces
|
||||
* visitor auth; `~gitbook/mcp/auth` always advertises auth so clients can opt
|
||||
* into adaptive content on non-VA sites.
|
||||
*/
|
||||
authRequired?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -18,7 +25,7 @@ const OAUTH_PROTECTED_RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-res
|
||||
* List of OAuth protected resources.
|
||||
*/
|
||||
const OAUTH_PROTECTED_RESOURCES: OAuthProtectedResource[] = [
|
||||
{ endpoint: '/~gitbook/mcp/auth', realm: 'mcp' },
|
||||
{ endpoint: '/~gitbook/mcp/auth', realm: 'mcp', authRequired: true },
|
||||
{ endpoint: '/~gitbook/mcp', realm: 'mcp' },
|
||||
];
|
||||
|
||||
@@ -138,6 +145,20 @@ export function isOAuthProtectedResourceMetadataRequest(
|
||||
return Boolean(getMatchedProtectedMetadataEndpoint(siteRequestURL));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a metadata request targets a resource that requires authentication
|
||||
* regardless of visitor auth (e.g. `~gitbook/mcp/auth`).
|
||||
*
|
||||
* On non-VA sites the base `~gitbook/mcp` endpoint is public, so we must not
|
||||
* advertise a PRM document for it: clients that discover PRM proactively would
|
||||
* otherwise start an OAuth flow against an endpoint that never issues a challenge.
|
||||
*/
|
||||
export function isOAuthProtectedResourceMetadataRequestForAuthEndpoint(
|
||||
siteRequestURL: URL | NextRequest['nextUrl']
|
||||
) {
|
||||
return Boolean(getMatchedProtectedMetadataEndpoint(siteRequestURL)?.authRequired);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the matched protected endpoint entry for a metadata doc request (if any).
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
type RevisionPage,
|
||||
RevisionPageLayoutOptionsCoverMask,
|
||||
RevisionPageLayoutOptionsCoverSize,
|
||||
RevisionPageLayoutOptionsWidth,
|
||||
} from '@gitbook/api';
|
||||
@@ -77,6 +78,7 @@ describe('resolveFirstDocument', () => {
|
||||
layout: {
|
||||
cover: true,
|
||||
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
|
||||
coverMask: RevisionPageLayoutOptionsCoverMask.None,
|
||||
title: true,
|
||||
description: true,
|
||||
tableOfContents: true,
|
||||
@@ -130,6 +132,7 @@ describe('resolveFirstDocument', () => {
|
||||
layout: {
|
||||
cover: true,
|
||||
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
|
||||
coverMask: RevisionPageLayoutOptionsCoverMask.None,
|
||||
title: true,
|
||||
description: true,
|
||||
tableOfContents: true,
|
||||
@@ -176,6 +179,7 @@ describe('resolvePagePath', () => {
|
||||
layout: {
|
||||
cover: true,
|
||||
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
|
||||
coverMask: RevisionPageLayoutOptionsCoverMask.None,
|
||||
title: true,
|
||||
description: true,
|
||||
tableOfContents: true,
|
||||
@@ -252,6 +256,7 @@ describe('resolvePagePath', () => {
|
||||
layout: {
|
||||
cover: true,
|
||||
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
|
||||
coverMask: RevisionPageLayoutOptionsCoverMask.None,
|
||||
title: true,
|
||||
description: true,
|
||||
tableOfContents: true,
|
||||
@@ -370,6 +375,7 @@ function createDocumentPage(id: string, path: string, hidden = false): RevisionP
|
||||
layout: {
|
||||
cover: true,
|
||||
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
|
||||
coverMask: RevisionPageLayoutOptionsCoverMask.None,
|
||||
title: true,
|
||||
description: true,
|
||||
tableOfContents: true,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Return the highest-scoring item in the list, or undefined when empty.
|
||||
*/
|
||||
export function getBestScoredResult<T extends { score: number }>(
|
||||
items: readonly T[]
|
||||
): T | undefined {
|
||||
return items.reduce<T | undefined>(
|
||||
(best, item) => (best === undefined || item.score > best.score ? item : best),
|
||||
undefined
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import { MiddlewareHeaders } from '@/lib/middleware';
|
||||
import {
|
||||
createOAuthProtectedResourceMetadataResponse,
|
||||
handleUnauthedOAuthProtectedResourceRequest,
|
||||
isOAuthProtectedResourceMetadataRequest,
|
||||
isOAuthProtectedResourceMetadataRequestForAuthEndpoint,
|
||||
isOAuthProtectedResourceRequest,
|
||||
} from '@/lib/oauth-protected';
|
||||
import { removeLeadingSlash, removeTrailingSlash } from '@/lib/paths';
|
||||
@@ -299,8 +299,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
}
|
||||
|
||||
// Handles OAuth protected resource metadata for non-VA adaptive content sites.
|
||||
// If the requested URL resolved directly to a site, synthesize the metadata response immediately.
|
||||
if (isOAuthProtectedResourceMetadataRequest(siteRequestURL)) {
|
||||
// Only the `~gitbook/mcp/auth` endpoint advertises auth here; the base `~gitbook/mcp`
|
||||
// endpoint stays public so clients doing proactive PRM discovery don't start an OAuth
|
||||
// flow against an endpoint that never issues a challenge.
|
||||
if (isOAuthProtectedResourceMetadataRequestForAuthEndpoint(siteRequestURL)) {
|
||||
return createOAuthProtectedResourceMetadataResponse({
|
||||
siteRequestURL,
|
||||
siteId: siteURLData.site,
|
||||
|
||||
@@ -770,6 +770,14 @@ const config: Config = {
|
||||
*/
|
||||
addVariant('page-api-block', 'body:has(.openapi-block) &');
|
||||
|
||||
/**
|
||||
* Variant for the page cover type
|
||||
*/
|
||||
addVariant(
|
||||
'page-cover-background',
|
||||
'body:has([data-gb-page-cover][data-cover-type="background"]) &'
|
||||
);
|
||||
|
||||
/**
|
||||
* Variant when the page is displayed in print mode.
|
||||
*/
|
||||
|
||||
@@ -55,35 +55,6 @@ it(
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
|
||||
it(
|
||||
'should record agent feedback through MCP',
|
||||
async () => {
|
||||
const client = new Client({
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(getContentTestURL('https://gitbook.com/docs/~gitbook/mcp/auth'))
|
||||
)
|
||||
);
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'sendFeedback',
|
||||
arguments: {
|
||||
category: 'content-gap',
|
||||
content: 'The authentication section does not explain how to rotate API tokens.',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.isError).toBeFalsy();
|
||||
// @ts-expect-error - response.content is of type unknown
|
||||
expect(response.content[0]?.text).toContain('Feedback recorded');
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
|
||||
it(
|
||||
'should get a page from another site space through MCP',
|
||||
async () => {
|
||||
|
||||
@@ -127,6 +127,19 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
|
||||
})(),
|
||||
}));
|
||||
break;
|
||||
case '@webframe.navigate':
|
||||
// Let the host navigate to another page. The destination is addressed by
|
||||
// `path`; the host resolves it within the current site and gates it.
|
||||
if (typeof message.action.path === 'string') {
|
||||
renderer.clientContext?.navigate?.({
|
||||
path: message.action.path,
|
||||
anchor:
|
||||
typeof message.action.anchor === 'string'
|
||||
? message.action.anchor
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
renderer.update({
|
||||
action: message.action,
|
||||
@@ -146,7 +159,7 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
|
||||
};
|
||||
}, [renderer, sendMessage]);
|
||||
|
||||
// Send data and client-only visitor context as state to the webframe.
|
||||
// Send data and client-only context (visitor claims) as state to the webframe.
|
||||
React.useEffect(() => {
|
||||
const abort = { cancelled: false };
|
||||
sendWebframeState({
|
||||
@@ -218,14 +231,14 @@ function resolveWebframeState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Read optional client-only visitor context.
|
||||
* Resolve the optional client-only contexts (visitor claims) to merge into the webframe state.
|
||||
*/
|
||||
async function resolveVisitorContext(clientContext: ContentKitClientContextData | undefined) {
|
||||
return await clientContext?.getVisitorContext?.();
|
||||
async function resolveClientContexts(clientContext: ContentKitClientContextData | undefined) {
|
||||
return await Promise.all([clientContext?.getVisitorContext?.()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the combined webframe state once visitor context has been resolved.
|
||||
* Send the combined webframe state once client-only contexts have been resolved.
|
||||
*/
|
||||
async function sendWebframeState(args: {
|
||||
elementData: ContentKitWebFrame['data'];
|
||||
@@ -236,14 +249,16 @@ async function sendWebframeState(args: {
|
||||
}) {
|
||||
const { elementData, rendererState, clientContext, sendMessage, abort } = args;
|
||||
const state = resolveWebframeState(elementData, rendererState);
|
||||
const visitorContext = await resolveVisitorContext(clientContext);
|
||||
const clientContexts = await resolveClientContexts(clientContext);
|
||||
|
||||
if (abort.cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof visitorContext !== 'undefined') {
|
||||
Object.assign(state, visitorContext);
|
||||
for (const context of clientContexts) {
|
||||
if (context) {
|
||||
Object.assign(state, context);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(state).length > 0) {
|
||||
|
||||
@@ -18,11 +18,22 @@ export type ContentKitRenderUpdate = Partial<
|
||||
>;
|
||||
|
||||
export type ContentKitClientContextData = {
|
||||
/**
|
||||
* Client-only visitor claims, merged into the webframe state.
|
||||
* Gated by the integration's visitor-claims scope.
|
||||
*/
|
||||
getVisitorContext?: () =>
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined
|
||||
| Promise<Record<string, unknown> | null | undefined>;
|
||||
|
||||
/**
|
||||
* Navigate the host page to another page, in response to a webframe `@webframe.navigate`
|
||||
* action. The destination is addressed by `path` (resolved against the site base path); the
|
||||
* host restricts navigation to destinations within the current site.
|
||||
*/
|
||||
navigate?: (target: { path: string; anchor?: string }) => void;
|
||||
};
|
||||
|
||||
export interface ContentKitClientContextType {
|
||||
|
||||
Reference in New Issue
Block a user