Merge branch 'main' of github.com:GitbookIO/gitbook into tomasz/rnd-12774-title-remains-partially-hidden-when-scrolling

This commit is contained in:
Tomek Gargula
2026-09-18 16:35:10 +02:00
17 changed files with 526 additions and 79 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Apply release notes tag filters from the URL via CSS before first paint, so the filtered entries, the "page contents" section list, and the tag chip highlighting all show correctly from the start on cached pages, with no flash of unfiltered content. Also stop the "page contents" scroll highlight from defaulting to an entry hidden by the filter.
@@ -70,6 +70,7 @@ runs:
GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }}
GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }}
GITBOOK_DISABLE_INSIGHTS: ${{ inputs.environment == 'preview' && 'true' || '' }}
GITBOOK_DISABLE_LOOKUP_ALTERNATIVES: ${{ inputs.environment == 'staging' && 'true' || '' }}
shell: bash
- name: Upload the DO worker
@@ -78,6 +78,11 @@ runs:
echo "GITBOOK_DISABLE_INSIGHTS=true" >> .vercel/.env.${{ inputs.environment }}.local
echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---"
cat .vercel/.env.${{ inputs.environment }}.local
- name: Inject staging build env vars
if: ${{ inputs.environment == 'staging' }}
shell: bash
run: |
echo "GITBOOK_DISABLE_LOOKUP_ALTERNATIVES=true" >> .vercel/.env.${{ inputs.environment }}.local
- name: Build Project Artifacts
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
shell: bash
+2
View File
@@ -7,6 +7,7 @@ import {
GITBOOK_APP_URL,
GITBOOK_ASSETS_URL,
GITBOOK_DISABLE_INSIGHTS,
GITBOOK_DISABLE_LOOKUP_ALTERNATIVES,
GITBOOK_DISABLE_TRACKING,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
@@ -37,6 +38,7 @@ export async function GET(_req: NextRequest) {
GITBOOK_INTEGRATIONS_CONTENT_HOST,
GITBOOK_DISABLE_TRACKING,
GITBOOK_DISABLE_INSIGHTS,
GITBOOK_DISABLE_LOOKUP_ALTERNATIVES,
// Secret envs
GITBOOK_SECRET: !!GITBOOK_SECRET,
@@ -9,8 +9,18 @@ import { Icon } from '@gitbook/icons';
import { TagIcon } from '../Tag';
import { Button } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
const UPDATES_FILTER_SEARCH_PARAM = 'tag';
import {
UPDATES_FILTER_KEY_ATTR,
UPDATES_FILTER_SEARCH_PARAM,
UPDATES_TAG_ATTR,
UPDATES_TAG_CHIP_SELECTED_ATTR,
UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR,
UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR,
UPDATES_TAG_CLEAR_ATTR,
UPDATES_TAG_FILTER_ATTR,
normalizeUpdatesFilterTags,
updatesFilterStyleKey,
} from '@/lib/updates';
type UpdatesFilterContextValue = {
selectedTags: string[];
@@ -36,21 +46,7 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
const availableTags = React.useMemo(() => new Set(tagSlugs), [tagSlugs]);
const sanitizeTags = React.useCallback(
(tags: string[]) => {
const next: string[] = [];
const seen = new Set<string>();
for (const tag of tags) {
if (!availableTags.has(tag) || seen.has(tag)) {
continue;
}
next.push(tag);
seen.add(tag);
}
return next;
},
(tags: string[]) => normalizeUpdatesFilterTags(tags, availableTags),
[availableTags]
);
@@ -63,8 +59,20 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
() => sanitizeTags(rawSelectedTags),
[sanitizeTags, rawSelectedTags]
);
const [selectedTags, setSelectedTags] = React.useState(urlSelectedTags);
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
const selectedTagsRef = React.useRef(selectedTags);
const styleKey = React.useMemo(() => updatesFilterStyleKey(tagSlugs), [tagSlugs]);
// Clean up on unmount so a nav to a page with no filterable updates doesn't leave a stale
// stylesheet hiding everything (see UPDATES_FILTER_KEY_ATTR).
React.useLayoutEffect(() => {
document.documentElement.setAttribute(UPDATES_FILTER_KEY_ATTR, styleKey);
return () => {
document.documentElement.removeAttribute(UPDATES_FILTER_KEY_ATTR);
document.documentElement.removeAttribute(UPDATES_TAG_FILTER_ATTR);
};
}, [styleKey]);
const replaceTags = React.useCallback(
(nextTags: string[]) => {
@@ -88,7 +96,10 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
}
}, [rawSelectedTags, replaceTags, urlSelectedTags]);
React.useEffect(() => {
// Layout effect so the filter attribute is applied before paint, avoiding a flash in the chip UI.
React.useLayoutEffect(() => {
applyTagFilterAttribute(urlSelectedTags);
if (areTagsEqual(selectedTagsRef.current, urlSelectedTags)) {
return;
}
@@ -104,6 +115,7 @@ export function UpdatesFilterProvider(props: { tagSlugs: string[]; children: Rea
selectedTagsRef.current = nextTags;
setSelectedTags(nextTags);
applyTagFilterAttribute(nextTags);
replaceTags(nextTags);
},
[replaceTags, sanitizeTags]
@@ -152,8 +164,7 @@ export function UpdatesTagFilters(props: {
clearLabel: string;
}) {
const { tags, tagsLabel, clearLabel } = props;
const { selectedTagSet, selectedTags, toggleTag, clearTags } = useUpdatesFilter();
const isFiltering = selectedTags.length > 0;
const { toggleTag, clearTags } = useUpdatesFilter();
if (tags.length === 0) {
return null;
@@ -166,64 +177,105 @@ export function UpdatesTagFilters(props: {
<Icon icon="tags" className="size-3" />
{tagsLabel}
</div>
{/* Visible/clickable only while a filter is active — see generateUpdatesFilterCSS. */}
<Button
variant="blank"
size="xsmall"
icon="xmark"
label={clearLabel}
onClick={isFiltering ? clearTags : undefined}
aria-hidden={!isFiltering}
tabIndex={isFiltering ? undefined : -1}
className={tcls('text-xs', !isFiltering && 'pointer-events-none invisible')}
onClick={clearTags}
{...{ [UPDATES_TAG_CLEAR_ATTR]: '' }}
className="pointer-events-none invisible text-xs"
/>
</div>
<div className="flex flex-wrap gap-1.5 px-3">
{tags.map((tag) => {
const selected = selectedTagSet.has(tag.slug);
return (
<button
key={tag.slug}
type="button"
aria-pressed={selected}
onClick={() => toggleTag(tag.slug)}
className={tcls(
'inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 font-medium text-xs leading-normal transition-colors',
'circular-corners:rounded-2xl straight-corners:rounded-xs',
'not-focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-primary',
selected
? 'bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover'
: 'bg-tint-5 text-tint-strong hover:bg-tint-hover',
isFiltering && !selected && 'opacity-8 hover:opacity-11'
)}
>
<TagIcon tag={tag} />
<span className="truncate">{tag.label}</span>
</button>
);
})}
{tags.map((tag) => (
<TagChip key={tag.slug} tag={tag} onToggle={toggleTag} />
))}
</div>
</div>
);
}
const CHIP_CLASS =
'inline-flex max-w-full rounded-full circular-corners:rounded-2xl straight-corners:rounded-xs not-focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-primary';
const CHIP_VARIANT_CLASS =
'max-w-full items-center gap-1 rounded-full px-2 py-1 font-medium text-xs leading-normal transition-colors circular-corners:rounded-2xl straight-corners:rounded-xs';
function TagChip(props: { tag: RevisionTag; onToggle: (tag: string) => void }) {
const { tag, onToggle } = props;
const { selectedTagSet } = useUpdatesFilter();
const onClick = () => onToggle(tag.slug);
return (
<button
type="button"
aria-pressed={selectedTagSet.has(tag.slug)}
onClick={onClick}
className={CHIP_CLASS}
>
<span
{...{ [UPDATES_TAG_CHIP_SELECTED_ATTR]: tag.slug }}
className={tcls(
CHIP_VARIANT_CLASS,
'hidden bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover'
)}
>
<TagIcon tag={tag} />
<span className="truncate">{tag.label}</span>
</span>
<span
{...{ [UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR]: tag.slug }}
className={tcls(
CHIP_VARIANT_CLASS,
'hidden bg-tint-5 text-tint-strong opacity-8 hover:bg-tint-hover hover:opacity-11'
)}
>
<TagIcon tag={tag} />
<span className="truncate">{tag.label}</span>
</span>
<span
{...{ [UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR]: tag.slug }}
className={tcls(
CHIP_VARIANT_CLASS,
'flex bg-tint-5 text-tint-strong hover:bg-tint-hover'
)}
>
<TagIcon tag={tag} />
<span className="truncate">{tag.label}</span>
</span>
</button>
);
}
function areTagsEqual(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((tag, index) => tag === right[index]);
}
/** Mirrors the active filter onto `<html>`, matching what the pre-paint script does on first load. */
function applyTagFilterAttribute(tags: string[]) {
if (typeof document === 'undefined') {
return;
}
if (tags.length > 0) {
document.documentElement.setAttribute(UPDATES_TAG_FILTER_ATTR, tags.join(' '));
} else {
document.documentElement.removeAttribute(UPDATES_TAG_FILTER_ATTR);
}
}
/** Visibility is driven purely by CSS against `data-update-tags` (see generateUpdatesFilterCSS). */
export function FilteredUpdate(props: {
tagSlugs: string[];
className?: string;
children: React.ReactNode;
}) {
const { tagSlugs, className, children } = props;
const { selectedTagSet } = useUpdatesFilter();
const isVisible =
selectedTagSet.size === 0 || tagSlugs.some((tagSlug) => selectedTagSet.has(tagSlug));
return (
<div className={className} hidden={!isVisible}>
<div className={className} {...{ [UPDATES_TAG_ATTR]: tagSlugs.join(' ') }}>
{children}
</div>
);
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'bun:test';
import { serializeUpdatesFilterScriptArgs } from './UpdatesFilterScript';
describe('serializeUpdatesFilterScriptArgs', () => {
it('cannot terminate the surrounding script element', () => {
const serialized = serializeUpdatesFilterScriptArgs([
['</script><script>globalThis.injected = true</script>'],
]);
expect(serialized).not.toContain('</script>');
expect(serialized).toContain('\\u003c/script>');
});
});
@@ -0,0 +1,95 @@
'use client';
import { useServerInsertedHTML } from 'next/navigation';
import { useRef } from 'react';
import {
UPDATES_FILTER_KEY_ATTR,
UPDATES_FILTER_SEARCH_PARAM,
UPDATES_TAG_FILTER_ATTR,
UPDATES_TAG_FILTER_CAP,
updatesFilterStyleKey,
} from '@/lib/updates';
/**
* Mirrors the `?tag=` search params onto `<html>` as `data-updates-tag-filter`, before first paint,
* so the generated CSS can filter with no flash. Also stamps the filter style key.
*
* NOTE: stringified and injected as an inline script — must be self-contained, touching only
* `document.documentElement` and `window.location`.
*/
export function applyUpdatesFilterScript(
searchParam: string,
attribute: string,
cap: number,
availableTags: string[],
keyAttribute: string,
styleKey: string
) {
const el = document.documentElement;
el.setAttribute(keyAttribute, styleKey);
try {
const validTags = new Set(availableTags);
const params = new URLSearchParams(window.location.search);
const seen = new Set<string>();
const slugs: string[] = [];
for (const value of params.getAll(searchParam)) {
const slug = value.trim();
if (!slug || seen.has(slug) || slugs.length >= cap || !validTags.has(slug)) {
continue;
}
seen.add(slug);
slugs.push(slug);
}
if (slugs.length > 0) {
el.setAttribute(attribute, slugs.join(' '));
} else {
el.removeAttribute(attribute);
}
} catch {
// Malformed URL — fall through, so the generated CSS's default (show everything) applies.
}
}
/**
* Inline script that applies the URL's `?tag=` filter to `<html>` before first paint. Rendered only
* on pages with filterable updates (see SitePage), not globally.
*/
export function UpdatesFilterScript(props: { tagSlugs: string[] }) {
const { tagSlugs } = props;
const inserted = useRef(false);
const scriptArgs = serializeUpdatesFilterScriptArgs([
UPDATES_FILTER_SEARCH_PARAM,
UPDATES_TAG_FILTER_ATTR,
UPDATES_TAG_FILTER_CAP,
tagSlugs,
UPDATES_FILTER_KEY_ATTR,
updatesFilterStyleKey(tagSlugs),
]);
// Only needed for the initial document — client navs are handled by UpdatesFilterProvider.
useServerInsertedHTML(() => {
if (inserted.current) {
return null;
}
inserted.current = true;
return (
<script
dangerouslySetInnerHTML={{
__html: `(${applyUpdatesFilterScript.toString()})(${scriptArgs})`,
}}
/>
);
});
return null;
}
/** Serialize arguments without allowing revision data to terminate the surrounding script tag. */
export function serializeUpdatesFilterScriptArgs(args: unknown[]): string {
return JSON.stringify(args).replaceAll('<', '\\u003c').slice(1, -1);
}
@@ -9,6 +9,7 @@ import { useScrollActiveId } from '@/components/hooks';
import { useBodyLoaded } from '@/components/primitives';
import type { DocumentSection } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import { UPDATES_TAG_SECTION_ATTR } from '@/lib/updates';
/**
* The threshold at which we consider a section as intersecting the viewport.
@@ -22,20 +23,18 @@ const ACTIVE_ITEM_OFFSET = 100;
export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }) {
const { selectedTagSet } = useUpdatesFilter();
const visibleSections = React.useMemo(() => {
if (selectedTagSet.size === 0) {
return sections;
}
return sections.filter((section) => {
if (section.tags === undefined) {
return true;
}
return section.tags.some((tagSlug) => selectedTagSet.has(tagSlug));
});
}, [sections, selectedTagSet]);
const ids = React.useMemo(() => visibleSections.map(({ id }) => id), [visibleSections]);
const ids = React.useMemo(
() =>
sections
.filter(
(section) =>
selectedTagSet.size === 0 ||
section.tags === undefined ||
section.tags.some((tagSlug) => selectedTagSet.has(tagSlug))
)
.map(({ id }) => id),
[sections, selectedTagSet]
);
const enabled = useBodyLoaded();
@@ -62,7 +61,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
className="relative flex flex-col border-tint-subtle pb-5 sidebar-list-line:border-l"
ref={scrollContainerRef}
>
{visibleSections.map((section) => (
{sections.map((section) => (
<li
key={section.id}
className={tcls(
@@ -76,6 +75,9 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
section.depth > 1 && ['ml-3', 'my-0', 'sidebar-list-line:ml-0']
)}
ref={activeId === section.id ? activeItemRef : null}
{...(section.tags !== undefined
? { [UPDATES_TAG_SECTION_ATTR]: section.tags.join(' ') }
: {})}
>
<a
href={`#${section.id}`}
@@ -14,6 +14,7 @@ import { PageContextProvider } from '../PageContext';
import { type PagePathParams, fetchPageData, getPathnameParam } from './fetch';
import { PageClientLayout } from './PageClientLayout';
import { UpdatesFilterProvider } from '@/components/DocumentView/UpdatesFilter';
import { UpdatesFilterScript } from '@/components/DocumentView/UpdatesFilterScript';
import { PageAside } from '@/components/PageAside';
import { PageBody, PageCover } from '@/components/PageBody';
import type { GitBookSiteContext } from '@/lib/context';
@@ -33,7 +34,11 @@ import {
resolveSiteSpaceCustomHomePage,
} from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { getDocumentFilterableTags } from '@/lib/updates';
import {
generateUpdatesFilterCSS,
getDocumentFilterableTags,
updatesFilterStyleHref,
} from '@/lib/updates';
import { getPageRSSURL } from '@/routes/rss';
export type SitePageProps = {
@@ -81,6 +86,7 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
} = await getSitePageData(props);
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
const filterableTags = document ? getDocumentFilterableTags(document, context.revision) : [];
const filterableTagSlugs = filterableTags.map((tag) => tag.slug);
const content = (
<>
{/* 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.
@@ -134,8 +140,10 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
return (
<IconsProvider iconSources={iconSources}>
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
{filterableTags.length > 0 ? (
<UpdatesFilterProvider tagSlugs={filterableTags.map((tag) => tag.slug)}>
{filterableTagSlugs.length > 0 ? (
<UpdatesFilterProvider tagSlugs={filterableTagSlugs}>
<UpdatesFilterScript tagSlugs={filterableTagSlugs} />
<UpdatesFilterStyle tagSlugs={filterableTagSlugs} />
{content}
</UpdatesFilterProvider>
) : (
@@ -146,6 +154,22 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
);
}
/**
* Stylesheet that resolves which `updates` entries the active `?tag=` filter shows, purely in CSS
* (see generateUpdatesFilterCSS). Byte-identical for every visitor, so it has no cache impact.
*/
function UpdatesFilterStyle({ tagSlugs }: { tagSlugs: string[] }) {
const css = generateUpdatesFilterCSS(tagSlugs);
if (!css) {
return null;
}
return (
<style href={updatesFilterStyleHref(tagSlugs)} precedence="high">
{css}
</style>
);
}
export async function generateSitePageViewport(context: GitBookSiteContext): Promise<Viewport> {
const { customization } = context;
@@ -70,7 +70,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
{hasDescendants ? (
<span
className={tcls(
'toc-group-chevron ml-auto flex shrink-0 transition-opacity duration-150',
'toc-group-chevron ml-auto mr-1 flex shrink-0 transition-opacity duration-150',
isOpen
? 'pointer-events-none opacity-0 delay-75'
: 'opacity-6 delay-0'
@@ -165,7 +165,7 @@ function Toggler(props: { isLinkActive: boolean; isOpen: boolean; onToggle: () =
iconOnly
variant="blank"
aria-hidden="true" // The button has no label or focus so hiding it from screen readers.
className="-my-0.5 ml-auto min-h-6 min-w-6 text-current hover:bg-tint-base"
className="-my-0.5 -mr-1 ml-auto min-h-6 min-w-6 text-current hover:bg-tint-base"
tabIndex={-1} // Prevent focus on the button since it's already inside a clickable link that performs the same toggle action.
/>
);
@@ -15,11 +15,12 @@ export function useScrollActiveId(
enabled: boolean;
} = { enabled: true }
): string | undefined {
const [activeId, setActiveId] = React.useState<string | undefined>(ids[0]);
const [activeId, setActiveId] = React.useState<string | undefined>(undefined);
const sectionsIntersectingMap = React.useRef<Map<string, boolean>>(new Map());
React.useEffect(() => {
const defaultActiveId = ids[0];
const defaultActiveId =
ids.find((id) => document.getElementById(id)?.offsetParent !== null) ?? ids[0];
sectionsIntersectingMap.current.clear();
setActiveId((activeId) =>
activeId !== undefined && ids.includes(activeId) ? activeId : defaultActiveId
+57 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'bun:test';
import { getURLLookupAlternatives, getURLLookupPathname, normalizeURL } from './urls';
import {
getURLLookupAlternatives,
getURLLookupPathname,
normalizeURL,
shouldBypassLookupAlternatives,
} from './urls';
describe('getURLLookupPathname', () => {
const previewRoot = 'https://sites.gitbook.com/preview/site_example/section';
@@ -832,3 +837,54 @@ describe('normalizeURL with encoded paths', () => {
expect(result.searchParams.get('filter')).toBe(risonValue);
});
});
describe('getURLLookupAlternatives with bypass', () => {
it('only looks up the full URL', () => {
expect(
getURLLookupAlternatives(new URL('https://docs.mycompany.com/a/b/c'), { bypass: true })
).toEqual({
revision: undefined,
changeRequest: undefined,
basePath: undefined,
urls: [{ url: 'https://docs.mycompany.com/a/b/c', extraPath: '', primary: true }],
});
});
it('only looks up the full URL for a variant', () => {
expect(
getURLLookupAlternatives(new URL('https://test.gitbook.io/v/variant/space'), {
bypass: true,
}).urls
).toEqual([
{ url: 'https://test.gitbook.io/v/variant/space', extraPath: '', primary: true },
]);
});
it.each(['revisions', 'changes'])('keeps the alternatives for %s', (kind) => {
const url = new URL(`https://docs.mycompany.com/a/~/${kind}/id/page`);
expect(getURLLookupAlternatives(url, { bypass: true })).toEqual(
getURLLookupAlternatives(url, { bypass: false })
);
});
});
describe('shouldBypassLookupAlternatives', () => {
const bypassURLs = ['https://docs.mycompany.com/section', 'https://other.mycompany.com/'];
it.each([
'https://docs.mycompany.com/section',
'https://docs.mycompany.com/section/page',
'https://other.mycompany.com/',
'https://other.mycompany.com/page',
])('matches %s', (url) => {
expect(shouldBypassLookupAlternatives(normalizeURL(new URL(url)), bypassURLs)).toBe(true);
});
it.each([
'https://docs.mycompany.com/sectionpage',
'https://docs.mycompany.com/',
'https://unknown.mycompany.com/section',
])('does not match %s', (url) => {
expect(shouldBypassLookupAlternatives(normalizeURL(new URL(url)), bypassURLs)).toBe(false);
});
});
+36 -1
View File
@@ -1,3 +1,4 @@
import { GITBOOK_DISABLE_LOOKUP_ALTERNATIVES } from '../env';
import { joinPath, removeTrailingSlash } from '../paths';
import { isProxyRootRequest } from '../proxy';
import { DataFetcherError, getExposableError } from './errors';
@@ -42,6 +43,34 @@ function getContentPathSegments(pathSegments: string[]): string[] {
return pathSegments;
}
/**
* Site URL prefixes resolved with the full URL only, for sites where a shorter alternative
* would resolve to the wrong content.
*/
const LOOKUP_ALTERNATIVES_BYPASS_URLS: string[] = ['https://proxy.gitbook.site/sites/site_p4Xo4'];
/**
* Whether the lookup of this (normalized) URL should skip the shorter alternatives.
*/
export function shouldBypassLookupAlternatives(
url: URL,
bypassURLs: string[] = LOOKUP_ALTERNATIVES_BYPASS_URLS
): boolean {
if (GITBOOK_DISABLE_LOOKUP_ALTERNATIVES) {
return true;
}
return bypassURLs.some((bypassURL) => {
const prefix = normalizeURL(new URL(bypassURL));
const prefixPath = removeTrailingSlash(prefix.pathname);
return (
prefix.origin === url.origin &&
(removeTrailingSlash(url.pathname) === prefixPath ||
url.pathname.startsWith(`${prefixPath}/`))
);
});
}
/**
* For a given GitBook URL, return a list of alternative URLs that could be matched against to lookup the content.
* The approach is optimized to aim at reusing cached lookup results as much as possible.
@@ -62,8 +91,9 @@ function getContentPathSegments(pathSegments: string[]): string[] {
* - Public content has a custom hostname in the organization with a variant: docs.company.com/<space>/v/<variant>/<path>
* - Public content has a custom hostname in the organization with a variant and a share-link: docs.company.com/<space>/<link>/v/<variant>/<path>
*/
export function getURLLookupAlternatives(input: URL) {
export function getURLLookupAlternatives(input: URL, options: { bypass?: boolean } = {}) {
const url = normalizeURL(input);
const bypass = options.bypass ?? shouldBypassLookupAlternatives(url);
let basePath: string | undefined = undefined;
let changeRequest: string | undefined = undefined;
@@ -123,6 +153,11 @@ export function getURLLookupAlternatives(input: URL) {
pushAlternative(contentURL, pathSegments.slice(revisionOrChangeIdIndex + 1).join('/'));
}
// Revisions and changes above still need their alternatives to extract the base path.
else if (bypass) {
pushAlternative(url, '');
}
// URL looks like a collection url (with /v/ in the path)
// We only start matching after the /v/ segment and we ignore everything before it
// to avoid potentially matching as a page not found under the default space in the collection
+6
View File
@@ -84,6 +84,12 @@ export const GITBOOK_DISABLE_TRACKING = Boolean(
*/
export const GITBOOK_DISABLE_INSIGHTS = process.env.GITBOOK_DISABLE_INSIGHTS === 'true';
/**
* Whether to resolve site content with the full URL only, skipping the shorter lookup alternatives.
*/
export const GITBOOK_DISABLE_LOOKUP_ALTERNATIVES =
process.env.GITBOOK_DISABLE_LOOKUP_ALTERNATIVES === 'true';
/**
* Hostname serving the integrations.
*/
+25 -1
View File
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'bun:test';
import type { JSONDocument, Revision } from '@gitbook/api';
import { getDocumentFilterableTags } from './updates';
import {
UPDATES_TAG_FILTER_CAP,
getDocumentFilterableTags,
normalizeUpdatesFilterTags,
} from './updates';
describe('getDocumentFilterableTags', () => {
it('returns unique update tags in document order', () => {
@@ -28,6 +32,26 @@ describe('getDocumentFilterableTags', () => {
});
});
describe('normalizeUpdatesFilterTags', () => {
it('trims, validates, deduplicates, and caps tags', () => {
const availableTags = new Set([
'fixes',
...Array.from({ length: UPDATES_TAG_FILTER_CAP }, (_, index) => `tag-${index}`),
]);
const tags = [
' fixes ',
'unknown',
'fixes',
...Array.from({ length: UPDATES_TAG_FILTER_CAP }, (_, index) => `tag-${index}`),
];
expect(normalizeUpdatesFilterTags(tags, availableTags)).toEqual([
'fixes',
...Array.from({ length: UPDATES_TAG_FILTER_CAP - 1 }, (_, index) => `tag-${index}`),
]);
});
});
function createUpdatesDocument(entryTags: string[][]): JSONDocument {
return {
object: 'document',
+125
View File
@@ -3,6 +3,65 @@ import type { JSONDocument, Revision, RevisionTag } from '@gitbook/api';
import { getBlocksByType } from './document';
import { getRevisionTags, resolveBlockTags } from './tags';
/** URL search param carrying the active tag filter, e.g. `?tag=changelog`. */
export const UPDATES_FILTER_SEARCH_PARAM = 'tag';
/** Attribute on `<html>` with the active filter's tag slugs, space-separated (e.g. `"a b"`). */
export const UPDATES_TAG_FILTER_ATTR = 'data-updates-tag-filter';
/**
* Attribute on `<html>` identifying which page's filter stylesheet is active (see
* `updatesFilterStyleKey`) — scopes the generated CSS so a stale, hoisted stylesheet from a
* previously visited page never matches the page that's actually mounted.
*/
export const UPDATES_FILTER_KEY_ATTR = 'data-updates-filter-key';
/** Attribute carrying an update entry's own tag slugs, space-separated. Read by the generated CSS. */
export const UPDATES_TAG_ATTR = 'data-update-tags';
/** Attribute carrying a "page contents" section's own tag slugs, space-separated (only set on sections that have tags). */
export const UPDATES_TAG_SECTION_ATTR = 'data-tag-section';
/** Safety cap on how many active tags are accepted from the URL or filter controls. */
export const UPDATES_TAG_FILTER_CAP = 20;
/** Keep in sync with the duplicated logic in `applyUpdatesFilterScript`. */
export function normalizeUpdatesFilterTags(
tags: string[],
availableTags: ReadonlySet<string>
): string[] {
const next: string[] = [];
const seen = new Set<string>();
for (const value of tags) {
const tag = value.trim();
if (
!tag ||
seen.has(tag) ||
next.length >= UPDATES_TAG_FILTER_CAP ||
!availableTags.has(tag)
) {
continue;
}
next.push(tag);
seen.add(tag);
}
return next;
}
// Chip look variants (see TagChip in UpdatesFilter.tsx) — CSS-toggled, not React state, for a
// flash-free first paint.
/** Marks a chip's "selected" span variant, valued with its own tag slug. */
export const UPDATES_TAG_CHIP_SELECTED_ATTR = 'data-tag-chip-selected';
/** Marks a chip's "unselected, dimmed" span variant. */
export const UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR = 'data-tag-chip-unselected-dimmed';
/** Marks a chip's "unselected, plain" span variant — visible by default. */
export const UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR = 'data-tag-chip-unselected-plain';
/** Marks the "clear filter" button — hidden until a filter is active. */
export const UPDATES_TAG_CLEAR_ATTR = 'data-updates-tag-clear';
/**
* Get the unique tags used by update entries in a document, preserving document order.
*/
@@ -29,3 +88,69 @@ export function getDocumentFilterableTags(
return tags;
}
/**
* Escape a slug for interpolation into a CSS string literal (a quoted attribute-selector value).
*/
function escapeCssString(value: string): string {
return value.replace(/["\\]/g, '\\$&');
}
// FNV-1a (32-bit) constants — see https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
const FNV_OFFSET_BASIS_32 = 0x811c9dc5;
const FNV_PRIME_32 = 0x01000193;
/** Stable, order-independent identifier for a page's set of filterable tags. */
export function updatesFilterStyleKey(tagSlugs: string[]): string {
const key = [...new Set(tagSlugs)].sort().join(' ');
let hash = FNV_OFFSET_BASIS_32;
for (let i = 0; i < key.length; i++) {
hash ^= key.charCodeAt(i);
hash = Math.imul(hash, FNV_PRIME_32);
}
return (hash >>> 0).toString(36);
}
/** React dedupes/hoists `<style href>` tags by this value, so each tag set needs its own href. */
export function updatesFilterStyleHref(tagSlugs: string[]): string {
return `gb-updates-filter-${updatesFilterStyleKey(tagSlugs)}`;
}
/**
* Generate the CSS that filters `updates` entries, "page contents" sections, and tag-filter chips by
* the active `?tag=` filter, purely via attribute selectors — so filtering applies before hydration
* even though the page's HTML doesn't vary by query string. Byte-identical per page, no cache impact.
*/
export function generateUpdatesFilterCSS(tagSlugs: RevisionTag['slug'][]): string {
const slugs = [...new Set(tagSlugs.filter(Boolean))];
if (slugs.length === 0) {
return '';
}
const key = escapeCssString(updatesFilterStyleKey(tagSlugs));
// Scoped behind this page's own key so a stale, hoisted stylesheet from another page never matches.
const scope = `html[${UPDATES_FILTER_KEY_ATTR}="${key}"]`;
const entry = `[${UPDATES_TAG_ATTR}]`;
const section = `[${UPDATES_TAG_SECTION_ATTR}]`;
// Leaves the `[` dangling — only ever used as `${scope}[${filtering} …` or `…:not(…`.
const filtering = `${UPDATES_TAG_FILTER_ATTR}]:not([${UPDATES_TAG_FILTER_ATTR}=""])`;
const rules = [
`${scope}[${filtering} ${entry}{display:none}`,
`${scope}[${filtering} ${section}{display:none}`,
`${scope}[${filtering} [${UPDATES_TAG_CHIP_UNSELECTED_PLAIN_ATTR}]{display:none}`,
`${scope}[${filtering} [${UPDATES_TAG_CLEAR_ATTR}]{visibility:visible;pointer-events:auto}`,
];
for (const slug of slugs) {
const value = escapeCssString(slug);
rules.push(
// `flex`, not `revert`, to match the entry's/section's own layout class.
`${scope}[${UPDATES_TAG_FILTER_ATTR}~="${value}"] ${entry}[${UPDATES_TAG_ATTR}~="${value}"]{display:flex}`,
`${scope}[${UPDATES_TAG_FILTER_ATTR}~="${value}"] ${section}[${UPDATES_TAG_SECTION_ATTR}~="${value}"]{display:flex}`,
`${scope}[${UPDATES_TAG_FILTER_ATTR}~="${value}"] [${UPDATES_TAG_CHIP_SELECTED_ATTR}="${value}"]{display:inline-flex}`,
`${scope}[${filtering}:not([${UPDATES_TAG_FILTER_ATTR}~="${value}"]) [${UPDATES_TAG_CHIP_UNSELECTED_DIMMED_ATTR}="${value}"]{display:inline-flex}`
);
}
return rules.join('');
}