Compare commits

...

3 Commits

Author SHA1 Message Date
Zeno Kapitein ddcb22e283 Format 2026-01-28 14:01:16 +01:00
Zeno Kapitein 0beed6bbf8 Update bun.lock 2026-01-28 13:59:52 +01:00
Marcos Cisneros 2d7954f478 Enhance layout handling for OpenAPI and full-width modes
- Removed deprecated configuration from bun.lock.
- Added new layout variants in tailwind.config.ts for better control over page structure.
- Updated DocumentView and related components to support full-width and OpenAPI layouts, ensuring proper styling and responsiveness.
- Introduced utility functions to check for OpenAPI blocks in documents.
- Adjusted various components to conditionally apply styles based on the layout mode, improving overall layout consistency.
2026-01-28 13:50:48 +01:00
18 changed files with 231 additions and 71 deletions
@@ -84,6 +84,11 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
ancestorBlocks: props.ancestorBlocks,
});
// Determine if this block should expand to full width or use readable width
// Text blocks (paragraphs, headings, lists) use a readable max-width
// Visual blocks (code, tables, images, etc.) expand to full width
const isFullWidthBlock = FULL_WIDTH_BLOCKS.includes(node.type);
return (
<Block
key={node.key || `${node.type}-${index}`}
@@ -94,7 +99,12 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
? 'max-w-screen-xl'
: 'max-w-3xl',
!LIST_BLOCKS.includes(node.type) && 'print:break-inside-avoid',
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
isFullWidthBlock && 'page-width-wide:max-w-full',
// In OpenAPI mode, all blocks expand to full width
// In full-width mode, content is capped at 64rem (1024px) and centered
'layout-openapi:max-w-full',
'layout-full-width:max-w-5xl',
'layout-full-width:mx-auto',
blockStyle,
]}
isEstimatedOffscreen={isOffscreen}
@@ -71,6 +71,8 @@ export function DocumentView(
style,
// Preserve adjacent whitespace and new lines.
'whitespace-pre-wrap',
// In OpenAPI mode, add left padding for sidebar offset
'layout-openapi:pl-12',
]}
context={context}
isOffscreen={isOffscreen}
@@ -85,7 +87,7 @@ export function DocumentViewSkeleton(props: { document: JSONDocument; blockStyle
const { document, blockStyle } = props;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 layout-openapi:pl-12">
{document.nodes.map((block) => (
<BlockSkeleton
key={block.key!}
@@ -95,6 +97,10 @@ export function DocumentViewSkeleton(props: { document: JSONDocument; blockStyle
block.data && 'fullWidth' in block.data && block.data.fullWidth
? 'max-w-screen-xl'
: 'max-w-3xl',
// Expand in OpenAPI mode, cap at 64rem in full-width mode
'layout-openapi:max-w-full',
'layout-full-width:max-w-5xl',
'layout-full-width:mx-auto',
blockStyle,
]}
/>
@@ -32,7 +32,16 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
})();
return (
<div className={tcls('mx-auto flex w-full max-w-3xl flex-row gap-4 md:gap-8', style)}>
<div
className={tcls(
'mx-auto flex w-full max-w-3xl flex-row gap-4 md:gap-8',
// Expand in OpenAPI mode, cap at 64rem in full-width mode
'layout-openapi:max-w-full',
'layout-full-width:max-w-5xl',
'layout-full-width:mx-auto',
style
)}
>
<div className="relative select-none">
<div
className={tcls(
@@ -224,7 +224,7 @@ const TabPanel = memo(function TabPanel(props: {
aria-labelledby={getTabButtonId(tab.id)}
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
>
<div className="p-4" hidden={!isActive}>
<div className={tcls('p-4')} hidden={!isActive}>
{tab.body}
</div>
</div>
@@ -48,16 +48,12 @@ export function Footer(props: { context: GitBookSiteContext }) {
>
<div
className={tcls(
'mx-auto flex @xs:grid @4xl:max-w-none! max-w-3xl site-width-wide:max-w-screen-2xl flex-col justify-between gap-12',
'mx-auto flex @xs:grid @4xl:max-w-none! layout-full-width:max-w-screen-2xl max-w-3xl flex-col justify-between gap-12',
'grid-cols-[auto_auto]',
'@4xl:grid-cols-[18rem_minmax(auto,48rem)_auto]',
'@7xl:grid-cols-[18rem_minmax(auto,48rem)_14rem]',
'@4xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_auto]',
'@7xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_14rem]',
'@4xl:page-no-toc:grid-cols-[minmax(auto,48rem)_auto]',
'@7xl:page-no-toc:grid-cols-[14rem_minmax(auto,48rem)_14rem]',
'@4xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[minmax(auto,90rem)_auto]',
'@7xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[14rem_minmax(auto,90rem)_14rem]'
'@4xl:layout-full-width:grid-cols-[minmax(auto,90rem)_auto]',
'@7xl:layout-full-width:grid-cols-[14rem_minmax(auto,90rem)_14rem]'
)}
>
{
@@ -115,10 +111,22 @@ export function Footer(props: { context: GitBookSiteContext }) {
customization.footer.groups?.length > 0 ? (
<div
className={tcls(
'@4xl:page-has-toc:col-span-1 @7xl:page-no-toc:col-span-1 col-span-2 @4xl:page-has-toc:col-start-2 @7xl:page-no-toc:col-start-2'
'col-span-2',
'@4xl:layout-default:col-span-1',
'@4xl:layout-default:col-start-2',
'@4xl:layout-openapi:col-span-1',
'@4xl:layout-openapi:col-start-2',
'@7xl:layout-full-width:col-span-1',
'@7xl:layout-full-width:col-start-2'
)}
>
<div className="mx-auto flex max-w-3xl site-width-wide:max-w-screen-2xl @xl:flex-row flex-col @xl:gap-6 gap-10">
<div
className={tcls(
'mx-auto flex max-w-3xl flex-col gap-10',
'layout-full-width:max-w-screen-2xl',
'@xl:flex-row @xl:gap-6'
)}
>
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
(column, columnIndex) => (
<div
@@ -105,7 +105,7 @@ export function Header(props: {
'hover:theme-bold:bg-header-link/3',
variants.generic.length > 1
? 'lg:hidden'
: 'page-no-toc:hidden lg:hidden'
: 'layout-full-width:hidden lg:hidden'
)}
/>
<HeaderLogo context={context} />
@@ -36,20 +36,39 @@ export function PageAside(props: {
className={tcls(
'group/aside',
'order-last',
'hidden',
'max-w-0',
'pt-8',
'pb-4',
'opacity-0',
'xl:flex',
// Hide by default
'hidden',
// Show on xl screens for default layout (normal sidebar)
'xl:layout-default:flex',
'xl:layout-default:max-w-56',
'xl:layout-default:opacity-11',
'xl:layout-default:ml-12',
// Show on xl screens for OpenAPI layout (floating overlay)
'xl:layout-openapi:flex',
'xl:layout-openapi:opacity-100',
'xl:layout-openapi:z-10',
'xl:layout-openapi:fixed',
'xl:layout-openapi:right-8',
'xl:layout-openapi:w-60',
'xl:layout-openapi:max-w-60',
'xl:layout-openapi:pb-8',
'xl:layout-openapi:pt-10',
'xl:layout-openapi:ml-0',
// Always hide for full-width layout
'layout-full-width:hidden!',
'overflow-hidden',
'xl:max-w-56',
'xl:opacity-11',
'xl:ml-12',
'max-w-0',
'opacity-0',
// Hide when chat is open
'xl:max-3xl:chat-open:hidden',
'xl:max-3xl:chat-open:max-w-0',
'xl:max-3xl:chat-open:opacity-0',
@@ -85,17 +104,7 @@ export function PageAside(props: {
// Client-side dynamic positioning (CSS vars applied by script)
'lg:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!',
'lg:[html[style*="--outline-height"]_&]:max-h-(--outline-height)!',
// When in api page mode, we display it as an overlay on non-large resolutions
'xl:max-2xl:page-api-block:z-10',
'xl:max-2xl:page-api-block:fixed',
'xl:max-2xl:page-api-block:right-8',
'xl:max-2xl:page-api-block:w-60',
'xl:max-2xl:page-api-block:max-w-60',
'xl:max-2xl:page-api-block:pb-8',
'xl:max-2xl:page-api-block:pt-10',
'xl:max-2xl:[body:has(.openapi-block):has(.page-has-ancestors)_&]:pt-6.5'
'lg:[html[style*="--outline-height"]_&]:max-h-(--outline-height)!'
)}
>
<div
@@ -104,17 +113,19 @@ export function PageAside(props: {
'min-w-56 shrink-0',
'overflow-hidden',
'w-full',
'xl:max-2xl:rounded-corners:page-api-block:rounded-md',
'xl:max-2xl:circular-corners:page-api-block:rounded-xl',
'xl:max-2xl:page-api-block:border',
'xl:max-2xl:page-api-block:border-tint',
'xl:max-2xl:page-api-block:bg-tint/9',
'xl:max-2xl:page-api-block:backdrop-blur-lg',
'xl:max-2xl:contrast-more:page-api-block:bg-tint',
'xl:max-2xl:page-api-block:hover:shadow-lg',
'xl:max-2xl:page-api-block:hover:shadow-tint-12/1',
'xl:max-2xl:dark:page-api-block:hover:shadow-tint-1/1',
'xl:max-2xl:page-api-block:not-hover:*:hidden'
// OpenAPI layout: floating card styles
'xl:layout-openapi:rounded-md',
'xl:layout-openapi:circular-corners:rounded-xl',
'xl:layout-openapi:border',
'xl:layout-openapi:border-tint',
'xl:layout-openapi:bg-tint/9',
'xl:layout-openapi:backdrop-blur-lg',
'xl:layout-openapi:contrast-more:bg-tint',
'xl:layout-openapi:hover:shadow-lg',
'xl:layout-openapi:hover:shadow-tint-12/1',
'xl:dark:layout-openapi:hover:shadow-tint-1/1',
'xl:layout-openapi:not-hover:*:hidden'
)}
>
<PageAsideHeader context={context} />
@@ -136,6 +147,10 @@ export function PageAside(props: {
);
}
/**
* Header for the aside that shows "ON THIS PAGE" label.
* Only visible in OpenAPI layout (floating overlay mode).
*/
function PageAsideHeader(props: { context: GitBookSiteContext }) {
const { context } = props;
const language = getSpaceLanguage(context);
@@ -144,7 +159,7 @@ function PageAsideHeader(props: { context: GitBookSiteContext }) {
<div
className={tcls(
'hidden',
'xl:max-2xl:page-api-block:flex!',
'xl:layout-openapi:flex!',
'text-xs',
'tracking-wide',
'font-semibold',
@@ -187,7 +202,8 @@ function PageAsideActions(props: {
className={tcls(
'flex flex-col gap-3',
'border-tint-subtle border-t first:border-none',
'sidebar-list-default:px-3 pt-5 first:pt-0 xl:max-2xl:page-api-block:p-5',
'sidebar-list-default:px-3 pt-5 first:pt-0',
'xl:layout-openapi:p-5',
'empty:hidden'
)}
>
@@ -209,7 +225,7 @@ async function PageAsideFooter(props: { context: GitBookSiteContext }) {
className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col',
'bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'border-tint-subtle xl:max-2xl:page-api-block:border-t xl:max-2xl:page-api-block:p-2',
'border-tint-subtle xl:layout-openapi:border-t xl:layout-openapi:p-2',
'pt-4'
)}
>
@@ -3,7 +3,13 @@ import type { JSONDocument, RevisionPageDocument, SiteInsightsDisplayContext } f
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import { hasFullWidthBlock, hasMoreThan, hasTopLevelBlock, isNodeEmpty } from '@/lib/document';
import {
hasFullWidthBlock,
hasMoreThan,
hasOpenAPIBlock,
hasTopLevelBlock,
isNodeEmpty,
} from '@/lib/document';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
@@ -41,6 +47,7 @@ export function PageBody(props: {
const { customization } = context;
const contentFullWidth = document ? hasFullWidthBlock(document) : false;
const contentHasOpenAPI = document ? hasOpenAPIBlock(document) : false;
// Update blocks can only be at the top level of the document, so we optimize the check.
const contentHasUpdates = document
@@ -55,8 +62,7 @@ export function PageBody(props: {
LINK_PREVIEW_MAX_COUNT
)
: false;
const pageWidthWide = page.layout.width === 'wide';
const siteWidthWide = pageWidthWide || contentFullWidth;
const language = getSpaceLanguage(context);
const updatedAt = page.updatedAt ?? page.createdAt;
@@ -65,7 +71,20 @@ export function PageBody(props: {
(page) => page.type !== 'document' || (page.type === 'document' && !page.hidden)
).length > 0;
const pageHasToc = page.layout.tableOfContents && hasVisibleTOCItems;
const hasTOC = page.layout.tableOfContents && hasVisibleTOCItems;
// Determine layout mode:
// 1. Full-width: No TOC
// 2. OpenAPI: Has TOC + (OpenAPI block OR wide property)
// 3. Default: Has TOC, no OpenAPI blocks, not wide
const layoutMode = !hasTOC
? 'layout-full-width'
: contentHasOpenAPI || page.layout.width === 'wide'
? 'layout-openapi'
: 'layout-default';
// Site-wide width only applies to full-width mode
const siteWidthWide = !hasTOC && (page.layout.width === 'wide' || contentFullWidth);
return (
<CurrentPageProvider page={{ spaceId: context.space.id, pageId: page.id }}>
@@ -73,15 +92,28 @@ export function PageBody(props: {
className={tcls(
'relative min-w-0 flex-1',
'max-w-screen-2xl py-8',
// In full-width layout, expand main to allow cover to go full width
'layout-full-width:max-w-full',
'layout-full-width:px-0',
// Allow words to break if they are too long.
'break-anywhere',
'@container',
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default',
// Layout mode class for CSS variants
layoutMode,
// Keep existing classes for backward compatibility
hasTOC ? 'page-has-toc' : 'page-no-toc',
siteWidthWide ? 'site-width-wide' : 'site-width-default',
pageHasToc ? 'page-has-toc' : 'page-no-toc'
// Only apply page-width-wide in full-width mode
!hasTOC && page.layout.width === 'wide'
? 'page-width-wide 3xl:px-8'
: 'page-width-default'
)}
>
<PreservePageLayout siteWidthWide={siteWidthWide} pageHasToc={pageHasToc} />
<PreservePageLayout
siteWidthWide={siteWidthWide}
layoutMode={layoutMode}
hasTOC={hasTOC}
/>
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
<PageCover as="hero" page={page} cover={page.cover} context={context} />
) : null}
@@ -128,7 +160,24 @@ export function PageBody(props: {
{
// TODO: after 25/07/2025, we can chage it to a true check as the cache will be updated
page.layout.metadata !== false ? (
<div className="mx-auto mt-6 page-api-block:ml-0 flex max-w-3xl page-full-width:max-w-screen-2xl flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong">
<div
className={tcls(
'mx-auto',
'mt-6',
'flex',
'max-w-3xl',
'flex-row',
'flex-wrap',
'items-center',
'gap-4',
'text-tint',
'contrast-more:text-tint-strong',
'layout-openapi:max-w-full',
'layout-openapi:pl-12',
'layout-full-width:max-w-5xl',
'layout-full-width:mx-auto'
)}
>
{updatedAt ? (
<p className="mr-auto text-sm ">
{t(
@@ -79,6 +79,9 @@ export async function PageBodyBlankslate(props: {
'grid',
'max-w-3xl',
'page-width-wide:max-w-screen-2xl',
// Expand to full width in OpenAPI and full-width layout modes
'layout-openapi:max-w-full',
'layout-full-width:max-w-full',
'w-full',
'mx-auto',
'gap-4',
@@ -83,22 +83,18 @@ export async function PageCover(props: {
data-full={String(as === 'full')}
className={tcls(
'overflow-hidden',
// Negative margin to balance the container padding
'-mx-4',
'flex',
'w-full',
'transition-all',
as === 'full'
? [
'sm:-mx-6',
'md:-mx-8',
!page.layout.tableOfContents &&
context.customization.header.preset !== 'none'
? [
'xl:-ml-76',
// Round the bottom corners once the page is wider than the image
'2xl:circular-corners:rounded-b-3xl 2xl:rounded-corners:rounded-b-xl',
]
: [
'lg:-ml-12',
// Round the bottom left corner once the sidebar is shown next to it
'lg:rounded-corners:rounded-bl-xl',
'lg:circular-corners:rounded-bl-3xl',
@@ -111,6 +107,11 @@ export async function PageCover(props: {
'sm:mx-auto',
'max-w-3xl ',
'page-width-wide:max-w-screen-2xl',
// In full-width layout, cover spans full viewport width
// Reset negative margins since main has no padding
'layout-full-width:mx-0',
'layout-full-width:max-w-full',
'layout-full-width:rounded-none',
'sm:rounded-corners:rounded-xl',
'sm:circular-corners:rounded-3xl',
'mb-8',
@@ -35,6 +35,11 @@ export async function PageFooterNavigation(props: {
'gap-2',
'max-w-3xl',
'page-width-wide:max-w-screen-2xl',
// Expand in OpenAPI mode, cap at 64rem in full-width mode
'layout-openapi:max-w-full',
'layout-openapi:pl-12',
'layout-full-width:max-w-5xl',
'layout-full-width:mx-auto',
'mx-auto',
'text-tint'
)}
@@ -52,11 +52,15 @@ export async function PageHeader(props: {
className={tcls(
'max-w-3xl',
'page-width-wide:max-w-screen-2xl',
// Expand in OpenAPI mode, cap at 64rem in full-width mode
'layout-openapi:max-w-full',
'layout-openapi:pl-12',
'layout-full-width:max-w-5xl',
'layout-full-width:mx-auto',
'mx-auto',
'mb-6',
'space-y-3',
'page-api-block:ml-0',
'page-api-block:max-w-full',
hasAncestors ? 'page-has-ancestors' : 'page-no-ancestors'
)}
>
@@ -1,20 +1,26 @@
'use client';
import * as React from 'react';
const LAYOUT_MODE_CLASSES = ['layout-default', 'layout-openapi', 'layout-full-width'] as const;
/**
* This component preserves the layout of the page while loading a new one.
* This approach is needed as page layout (full width block) is done using CSS (`body:has(.full-width)`),
* This approach is needed as page layout is done using CSS (`body:has(.layout-*)`),
* which becomes false while transitioning between the 2 page states:
*
* 1. Page 1 with full width block: `body:has(.site-width-wide)` is true
* 2. Loading skeleton while transitioning to page 2: `body:has(.site-width-wide)` is false
* 3. Page 2 with full width block: `body:has(.site-width-wide)` is true
* 1. Page 1 with layout mode: `body:has(.layout-default)` is true
* 2. Loading skeleton while transitioning to page 2: `body:has(.layout-default)` is false
* 3. Page 2 with layout mode: `body:has(.layout-default)` is true
*
* This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2).
* It also preserves the page TOC state (page-has-toc/page-no-toc) to prevent logo sizing issues during navigation.
*/
export function PreservePageLayout(props: { siteWidthWide: boolean; pageHasToc: boolean }) {
const { siteWidthWide, pageHasToc } = props;
export function PreservePageLayout(props: {
siteWidthWide: boolean;
layoutMode: (typeof LAYOUT_MODE_CLASSES)[number];
hasTOC: boolean;
}) {
const { siteWidthWide, layoutMode, hasTOC } = props;
React.useLayoutEffect(() => {
// We use the header as it's an element preserved between page transitions
@@ -24,20 +30,29 @@ export function PreservePageLayout(props: { siteWidthWide: boolean; pageHasToc:
return;
}
// Clear all layout mode classes first
for (const cls of LAYOUT_MODE_CLASSES) {
header.classList.remove(cls);
}
// Add current layout mode
header.classList.add(layoutMode);
if (siteWidthWide) {
header.classList.add('site-width-wide');
} else {
header.classList.remove('site-width-wide');
}
if (pageHasToc) {
// Preserve page TOC state to prevent logo sizing issues during navigation
if (hasTOC) {
header.classList.add('page-has-toc');
header.classList.remove('page-no-toc');
} else {
header.classList.add('page-no-toc');
header.classList.remove('page-has-toc');
}
}, [siteWidthWide, pageHasToc]);
}, [siteWidthWide, layoutMode, hasTOC]);
return null;
}
@@ -71,7 +71,13 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
{/* 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
? 'flex grow flex-col justify-start'
: 'contents'
}
>
{withFullPageCover && page.cover ? (
<PageCover as="full" page={page} cover={page.cover} context={context} />
) : null}
@@ -129,7 +129,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
'lg:flex-row',
'lg:justify-center',
CONTAINER_STYLE,
'site-width-wide:max-w-screen-4xl',
'layout-full-width:max-w-screen-4xl',
'transition-[max-width] duration-300',
// Ensure the footer is display below the viewport even if the content is not enough
@@ -51,6 +51,10 @@ export async function TableOfContents(props: {
'max-lg:not-sidebar-filled:border-r',
'border-tint-subtle',
// Hide completely in full-width mode
'layout-full-width:hidden!',
'lg:layout-full-width:hidden!',
'lg:flex!',
'lg:animate-none!',
'lg:sticky',
+14
View File
@@ -32,6 +32,20 @@ export function hasFullWidthBlock(document: JSONDocument): boolean {
});
}
/**
* Check if the document contains an OpenAPI block at the top level.
*/
export function hasOpenAPIBlock(document: JSONDocument): boolean {
return hasTopLevelBlock(document, (block) => {
return (
block.type === 'swagger' ||
block.type === 'openapi-operation' ||
block.type === 'openapi-schemas' ||
block.type === 'openapi-webhook'
);
});
}
/**
* Check if a top level block matches a predicate.
*/
+10
View File
@@ -677,6 +677,16 @@ const config: Config = {
*/
addVariant('page-api-block', 'body:has(.openapi-block) &');
/**
* Layout mode variants for controlling the page structure.
* - layout-default: 3-column layout (TOC + Content + Outline)
* - layout-openapi: 2-column layout (TOC + Content, no Outline)
* - layout-full-width: 1-column layout (Content only, no TOC or Outline)
*/
addVariant('layout-default', 'body:has(.layout-default) &');
addVariant('layout-openapi', 'body:has(.layout-openapi) &');
addVariant('layout-full-width', 'body:has(.layout-full-width) &');
/**
* Variant when the page is displayed in print mode.
*/