diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ec6def228..96e010216 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,7 +20,7 @@ jobs: - name: Setup bun uses: oven-sh/setup-bun@v1 with: - bun-version: 1.0.33 + bun-version: 1.1.18 - name: Install dependencies run: bun install --frozen-lockfile env: @@ -82,7 +82,7 @@ jobs: - name: Setup bun uses: oven-sh/setup-bun@v1 with: - bun-version: 1.0.33 + bun-version: 1.1.18 - name: Install dependencies run: bun install --frozen-lockfile - name: Setup Playwright @@ -102,7 +102,7 @@ jobs: - name: Setup bun uses: oven-sh/setup-bun@v1 with: - bun-version: 1.0.33 + bun-version: 1.1.18 - name: Install dependencies run: bun install --frozen-lockfile env: @@ -121,7 +121,7 @@ jobs: - name: Setup bun uses: oven-sh/setup-bun@v1 with: - bun-version: 1.0.33 + bun-version: 1.1.18 - name: Install dependencies run: bun install --frozen-lockfile env: @@ -136,7 +136,7 @@ jobs: - name: Setup bun uses: oven-sh/setup-bun@v1 with: - bun-version: 1.0.33 + bun-version: 1.1.18 - name: Install dependencies run: bun install --frozen-lockfile env: @@ -151,7 +151,7 @@ jobs: - name: Setup bun uses: oven-sh/setup-bun@v1 with: - bun-version: 1.0.33 + bun-version: 1.1.18 - name: Install dependencies run: bun install --frozen-lockfile env: @@ -166,7 +166,7 @@ jobs: - name: Setup bun uses: oven-sh/setup-bun@v1 with: - bun-version: 1.0.33 + bun-version: 1.1.18 - name: Install dependencies run: bun install --frozen-lockfile env: diff --git a/bun.lockb b/bun.lockb index 0bd371d2a..2536501e4 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index ba0642c77..a07840fdb 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ ], "dependencies": { "@geist-ui/icons": "^1.0.2", - "@gitbook/api": "^0.52.0", + "@gitbook/api": "^0.53.0", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-popover": "^1.0.7", "@sentry/nextjs": "^7.94.1", diff --git a/packages/react-openapi/src/OpenAPISchema.tsx b/packages/react-openapi/src/OpenAPISchema.tsx index 6a9dfab7e..ce6fb86ba 100644 --- a/packages/react-openapi/src/OpenAPISchema.tsx +++ b/packages/react-openapi/src/OpenAPISchema.tsx @@ -48,8 +48,12 @@ export function OpenAPISchemaProperty( : getSchemaAlternatives(schema, new Set(circularRefs.keys())); const shouldDisplayExample = (schema: OpenAPIV3.SchemaObject): boolean => { - return (typeof schema.example === 'string' || typeof schema.example === 'number' || typeof schema.example === 'boolean') - } + return ( + typeof schema.example === 'string' || + typeof schema.example === 'number' || + typeof schema.example === 'boolean' + ); + }; return ( ) : null} {shouldDisplayExample(schema) ? ( - Example: {JSON.stringify(schema.example)} + + Example: {JSON.stringify(schema.example)} + ) : null} } diff --git a/src/app/(global)/~gitbook/image/route.ts b/src/app/(global)/~gitbook/image/route.ts index 429274564..dae9da5c6 100644 --- a/src/app/(global)/~gitbook/image/route.ts +++ b/src/app/(global)/~gitbook/image/route.ts @@ -1,6 +1,11 @@ import { NextRequest } from 'next/server'; -import { verifyImageSignature, resizeImage, CloudflareImageOptions, checkIsSizableImageURL } from '@/lib/images'; +import { + verifyImageSignature, + resizeImage, + CloudflareImageOptions, + checkIsSizableImageURL, +} from '@/lib/images'; import { parseImageAPIURL } from '@/lib/urls'; export const runtime = 'edge'; diff --git a/src/app/(space)/(content)/[[...pathname]]/page.tsx b/src/app/(space)/(content)/[[...pathname]]/page.tsx index 321c701fd..8385ca556 100644 --- a/src/app/(space)/(content)/[[...pathname]]/page.tsx +++ b/src/app/(space)/(content)/[[...pathname]]/page.tsx @@ -27,6 +27,7 @@ export default async function Page(props: { params: PagePathParams }) { content: contentPointer, contentTarget, space, + parent, customization, pages, page, @@ -84,6 +85,7 @@ export default async function Page(props: { params: PagePathParams }) { {page.layout.outline ? ( (null); + const [visible, setVisible] = React.useState(false); + const [failed, setFailed] = React.useState(false); + const [ad, setAd] = React.useState(undefined); + + // Observe the container visibility + React.useEffect(() => { + if (!containerRef.current) { + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + } + }, + { + root: null, + rootMargin: '0px', + threshold: 0.1, + }, + ); + + observer.observe(containerRef.current); + + return () => { + observer.disconnect(); + }; + }, []); + + // When the container is visible, + // track an impression on the ad and fetch it + React.useEffect(() => { + if (!visible) { + return; + } + + let cancelled = false; + + (async () => { + const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`); + url.searchParams.set('segment', `placement:${placement}`); + url.searchParams.set('v', 'true'); + if (ignore) { + url.searchParams.set('ignore', 'true'); + } + + try { + const res = await fetch(url); + const json: AdsResponse = await res.json(); + + if (cancelled) { + return; + } + + const first = json.ads[0]; + if (first && 'active' in first) { + setAd(first); + } + } catch (error) { + console.error( + 'Failed to fetch ad, it might have been blocked by a ad-blocker', + error, + ); + setFailed(true); + } + })(); + + return () => { + cancelled = true; + }; + }, [visible, zoneId, ignore, placement]); + + const viaUrl = new URL('https://www.gitbook.com'); + viaUrl.searchParams.set('utm_source', 'content'); + viaUrl.searchParams.set('utm_medium', 'ads'); + viaUrl.searchParams.set('utm_campaign', spaceId); + + if (ad) { + console.log('ad', ad); + } + + return ( +
+ {ad ? ( + <> + {mode === 'classic' || !('callToAction' in ad) ? ( + + ) : ( + + )} + {ad.pixel ? : null} +

+ + Ads via GitBook + +

+ + ) : failed ? ( + + ) : null} +
+ ); +} + +/** + * Render attribution or verification pixels. + * https://docs.buysellads.com/ad-serving-api#pixels + */ +function AdPixels({ rawPixel }: { rawPixel: string }) { + const pixels = rawPixel.split('||'); + const time = String(Math.round(Date.now() / 1e4) | 0); + + return ( +
+ {pixels.map((pixel, index) => { + return ( + Ads tracking pixel + ); + })} +
+ ); +} + +/** + * Placeholder when visitor has an ad-blocker. + */ +function AdBlockerPlaceholder() { + return ( +
+
+ +

Ad disabled

+
+

+ {`It looks like you're using an adblocker. Whitelist this site to help support this + project.`} +

+
+ ); +} diff --git a/src/components/Ads/AdClassicRendering.tsx b/src/components/Ads/AdClassicRendering.tsx new file mode 100644 index 000000000..eb9585b08 --- /dev/null +++ b/src/components/Ads/AdClassicRendering.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; + +import { tcls } from '@/lib/tailwind'; + +import { AdItem } from './types'; + +/** + * Classic rendering for an ad. + */ +export function AdClassicRendering({ ad }: { ad: AdItem }) { + return ( + + {'smallImage' in ad ? ( +
+ Ads logo +
+ ) : ( +
+ Ads logo +
+ )} +
+
{ad.description}
+
+
+ ); +} diff --git a/src/components/Ads/AdCoverRendering.tsx b/src/components/Ads/AdCoverRendering.tsx new file mode 100644 index 000000000..7a2aab5f4 --- /dev/null +++ b/src/components/Ads/AdCoverRendering.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; + +import { hexToRgba } from '@/lib/colors'; +import { tcls } from '@/lib/tailwind'; + +import { AdCover } from './types'; + +/** + * Cover rendering for an ad. + */ +export function AdCoverRendering({ ad }: { ad: AdCover }) { + return ( + +
+ +
+ Large image +
+
+ {ad.company} +
+
+
{ad.companyTagline}
+
+ {ad.description} +
+
+
+ + {ad.callToAction} + +
+
+ + ); +} diff --git a/src/components/Ads/index.ts b/src/components/Ads/index.ts new file mode 100644 index 000000000..b9dc3e63a --- /dev/null +++ b/src/components/Ads/index.ts @@ -0,0 +1 @@ +export * from './Ad'; diff --git a/src/components/Ads/types.ts b/src/components/Ads/types.ts new file mode 100644 index 000000000..e4cbb3be7 --- /dev/null +++ b/src/components/Ads/types.ts @@ -0,0 +1,51 @@ +export interface AdGeneric { + active: string; + ad_via_link: string; + bannerid: string; + creativeid: string; + evenodd: string; + external_id: string; + height: string; + i: string; + identifier: string; + longimp: string; + longlink: string; + num_slots: string; + statimp: string; + statlink: string; + timestamp: string; + width: string; + zoneid: string; + zonekey: string; + rendering: 'carbon'; + pixel?: string; +} + +export interface AdClassic extends AdGeneric { + description: string; + smallImage: string; +} + +export interface AdCover extends AdGeneric { + backgroundColor: string; + backgroundHoverColor?: string; + textColor?: string; + textColorHover?: string; + callToAction: string; + company: string; + companyTagline: string; + description: string; + largeImage: string; + image?: string; + logo: string; + ctaBackgroundColor?: string; + ctaBackgroundHoverColor?: string; + ctaTextColor?: string; + ctaTextColorHover?: string; +} + +export type AdItem = AdClassic | AdCover; + +export interface AdsResponse { + ads: Array; +} diff --git a/src/components/DocumentView/Embed.tsx b/src/components/DocumentView/Embed.tsx index e0f3597cd..a949a9e0c 100644 --- a/src/components/DocumentView/Embed.tsx +++ b/src/components/DocumentView/Embed.tsx @@ -14,7 +14,7 @@ import { IntegrationBlock } from './Integration'; export async function Embed(props: BlockProps) { const { block, context, ...otherProps } = props; const nonce = headers().get('x-nonce') || undefined; - + ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script', nonce }); const { data: embed } = await (context.content diff --git a/src/components/DocumentView/OpenAPI/scalar.css b/src/components/DocumentView/OpenAPI/scalar.css index e9e215216..e23957d82 100644 --- a/src/components/DocumentView/OpenAPI/scalar.css +++ b/src/components/DocumentView/OpenAPI/scalar.css @@ -5,8 +5,16 @@ rgb(var(--primary-base-300, 180 180 180)), rgb(var(--dark-base, 23 23 23)) 96% ); - --scalar-color-2: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.72)); - --scalar-color-3: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.4)); + --scalar-color-2: color-mix( + in srgb, + var(--scalar-color-1), + transparent calc(100% - 100% * 0.72) + ); + --scalar-color-3: color-mix( + in srgb, + var(--scalar-color-1), + transparent calc(100% - 100% * 0.4) + ); --scalar-color-accent: #007d9c; --scalar-background-1: rgb(var(--light-base, 255 255 255)); @@ -50,8 +58,16 @@ rgb(var(--primary-base-700, 70 70 70)), rgb(var(--light-base, 255 255 255)) 100% ); - --scalar-color-2: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.64)); - --scalar-color-3: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.4)); + --scalar-color-2: color-mix( + in srgb, + var(--scalar-color-1), + transparent calc(100% - 100% * 0.64) + ); + --scalar-color-3: color-mix( + in srgb, + var(--scalar-color-1), + transparent calc(100% - 100% * 0.4) + ); --scalar-color-accent: #50b7e0; --scalar-background-1: rgb(var(--dark-base, 22 22 22)); @@ -330,4 +346,4 @@ .scalar .custom-scroll { padding-right: 12px; } -} \ No newline at end of file +} diff --git a/src/components/PageAside/PageAside.tsx b/src/components/PageAside/PageAside.tsx index 28e3b0306..cae2de1af 100644 --- a/src/components/PageAside/PageAside.tsx +++ b/src/components/PageAside/PageAside.tsx @@ -6,6 +6,7 @@ import { CustomizationSettings, JSONDocument, RevisionPageDocument, + Site, SiteCustomizationSettings, Space, } from '@gitbook/api'; @@ -17,9 +18,10 @@ import { getDocumentSections } from '@/lib/document'; import { absoluteHref } from '@/lib/links'; import { ContentRefContext, resolveContentRef } from '@/lib/references'; import { tcls } from '@/lib/tailwind'; -import { getPDFUrl, getPDFUrlSearchParams } from '@/lib/urls'; +import { getPDFUrlSearchParams } from '@/lib/urls'; import { ScrollSectionsList } from './ScrollSectionsList'; +import { Ad } from '../Ads'; import { PageFeedbackForm } from '../PageFeedback'; /** @@ -27,6 +29,7 @@ import { PageFeedbackForm } from '../PageFeedback'; */ export async function PageAside(props: { space: Space; + site: Site | undefined; customization: CustomizationSettings | SiteCustomizationSettings; page: RevisionPageDocument; document: JSONDocument | null; @@ -35,8 +38,16 @@ export async function PageAside(props: { withFullPageCover: boolean; withPageFeedback: boolean; }) { - const { space, page, document, customization, withHeaderOffset, withPageFeedback, context } = - props; + const { + space, + site, + page, + document, + customization, + withHeaderOffset, + withPageFeedback, + context, + } = props; const language = getSpaceLanguage(customization); return ( @@ -172,6 +183,15 @@ export async function PageAside(props: { ) : null}
+ {site?.ads ? ( + + ) : null} ); } diff --git a/src/lib/colors.ts b/src/lib/colors.ts index b798c2b1d..25a1ca0d8 100644 --- a/src/lib/colors.ts +++ b/src/lib/colors.ts @@ -16,6 +16,15 @@ export function hexToRgb(hex: string): string { return `${r} ${g} ${b}`; } +/** + * Convert a hex color to a RGBA color. + */ +export function hexToRgba(hex: string, alpha: number): string { + const [r, g, b] = hexToRgbArray(hex); + // Return the RGBA values separated by spaces + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + /** * Generate Tailwind-compatible shades from a single color * @param {string} hex The hex code to generate shades from diff --git a/src/lib/csp.ts b/src/lib/csp.ts index 4cbd46c57..4f7d2ceff 100644 --- a/src/lib/csp.ts +++ b/src/lib/csp.ts @@ -38,7 +38,7 @@ export function getContentSecurityPolicy(scripts: SpaceIntegrationScript[], nonc script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'unsafe-inline' 'unsafe-eval' ${assetsDomain} https://integrations.gitbook.com https://cdn.iframe.ly; style-src 'self' ${assetsDomain} fonts.googleapis.com 'unsafe-inline'; img-src * 'self' blob: data: files.gitbook.com ${assetsDomain}; - connect-src * 'self' integrations.gitbook.com app.gitbook.com api.gitbook.com ${assetsDomain}; + connect-src * 'self' integrations.gitbook.com app.gitbook.com api.gitbook.com srv.buysellads.com ${assetsDomain}; font-src 'self' fonts.gstatic.com ${assetsDomain}; frame-src *; object-src 'none';