Compare commits

..

4 Commits

Author SHA1 Message Date
Brett Jephson 078eacce45 changeset 2026-09-28 22:19:28 +01:00
Brett Jephson 9f168ff93b Resolve a page link by its recorded path when its page is gone
A page or anchor ref whose page ID no longer exists now looks for a page at
the path recorded on the ref, so a link keeps working after its target is
deleted and re-created at the same path. The ID still wins while it exists,
so moves and renames resolve as before. The API records the path on refs it
writes; until @gitbook/api ships the field, it is read untyped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rt9YwdSUu7gHehYbEkvEJ8
2026-09-28 22:19:18 +01:00
Peter White df2841cf36 Don't leak the internal route into the site auth login link (#4642) 2026-09-28 17:43:23 +02:00
Tomek 70bfdda39c Restore Next.js 16.3.6 dev-mode OOM patch (#4641) 2026-09-28 11:40:41 +00:00
53 changed files with 172 additions and 222 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep links working when their target page is deleted and re-created at the same path, by falling back to the path recorded on the link.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Keep links to GitBook app URLs of spaces in the same site on the site, instead of sending readers to app.gitbook.com. A link to a page that no longer exists now opens its space on the site, and its "Page not found" card says which space it opens.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Restore the Next.js dev-mode OOM patch that was silently dropped by the 16.3.6 upgrade (root `package.json`'s `patchedDependencies` still pinned it to the old `16.3.3` patch file, so Bun stopped applying it).
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the site auth login link sometimes redirecting back to an internal URL after login.
+1
View File
@@ -342,6 +342,7 @@
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"next@16.3.6": "patches/next@16.3.6.patch",
},
"overrides": {
"@codemirror/state": "6.4.1",
+1 -1
View File
@@ -68,6 +68,6 @@
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
"next@16.3.3": "patches/next@16.3.3.patch"
"next@16.3.6": "patches/next@16.3.6.patch"
}
}
@@ -24,12 +24,12 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
: null;
if (!resolved) {
const fallback = resolveContentRefFallback(block.data.ref, context.contentContext);
const fallback = resolveContentRefFallback(block.data.ref);
if (!fallback) {
return null;
}
return (
<NotFoundRefHoverCard context={context} fallback={fallback}>
<NotFoundRefHoverCard context={context}>
<BlockContentRefCard
contentRef={block.data.ref}
resolved={fallback}
@@ -79,11 +79,9 @@ export async function InlineLinkButton(
? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext)
: null;
const fallback =
!resolved && inline.data.ref
? resolveContentRefFallback(inline.data.ref, context.contentContext)
: null;
const href = resolved?.href ?? fallback?.href;
const href =
resolved?.href ??
(inline.data.ref ? resolveContentRefFallback(inline.data.ref)?.href : undefined);
const sharedProps: React.ComponentProps<typeof Button> = {
...buttonProps,
insights: {
@@ -107,11 +105,7 @@ export async function InlineLinkButton(
);
if (inline.data.ref && !resolved) {
return (
<NotFoundRefHoverCard context={context} fallback={fallback}>
{button}
</NotFoundRefHoverCard>
);
return <NotFoundRefHoverCard context={context}>{button}</NotFoundRefHoverCard>;
}
return button;
@@ -13,7 +13,6 @@ import {
resolveContentRefFallback,
resolveContentRefInDocument,
} from '@/lib/references';
import { checkIsExternalURL } from '@/lib/urls';
export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const { document, inline, context, ancestorInlines } = props;
@@ -37,15 +36,11 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
);
if (!resolved) {
const fallback = resolveContentRefFallback(inline.data.ref, contentContext);
const fallback = resolveContentRefFallback(inline.data.ref);
return (
<NotFoundRefHoverCard context={context} fallback={fallback}>
<NotFoundRefHoverCard context={context}>
{fallback ? (
<InlineLinkAnchor
href={fallback.href}
contentRef={inline.data.ref}
isExternal={checkIsExternalURL(fallback.href)}
>
<InlineLinkAnchor href={fallback.href} contentRef={inline.data.ref} isExternal>
{inlinesElement}
</InlineLinkAnchor>
) : (
@@ -58,7 +53,7 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
<InlineLinkAnchor
href={resolved.href}
contentRef={inline.data.ref}
isExternal={isExternalLink(inline, resolved)}
isExternal={inline.data.ref.kind === 'url'}
>
{inlinesElement}
</InlineLinkAnchor>
@@ -126,7 +121,7 @@ function InlineLinkTooltipWrapper(props: {
let breadcrumbs = resolved.ancestors ?? [];
const isMailto = resolved.href.startsWith('mailto:');
const isExternal = isExternalLink(inline, resolved);
const isExternal = inline.data.ref.kind === 'url';
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
if (isMailto) {
@@ -166,10 +161,3 @@ function InlineLinkTooltipWrapper(props: {
</InlineLinkTooltip>
);
}
/**
* A URL link resolved to a path in the site is internal, even though its ref is a URL.
*/
function isExternalLink(inline: DocumentInlineLink, resolved: ResolvedContentRef): boolean {
return inline.data.ref.kind === 'url' && checkIsExternalURL(resolved.href);
}
@@ -2,10 +2,8 @@ import { Icon } from '@gitbook/icons';
import type { DocumentContextProps } from '../DocumentView';
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
import { getSpaceLanguage, t, tString } from '@/intl/server';
import { getSpaceLanguage, tString } from '@/intl/server';
import { defaultLanguage } from '@/intl/translations';
import type { ResolvedContentRef } from '@/lib/references';
import { checkIsExternalURL } from '@/lib/urls';
/**
* Hover card displayed for a link not found.
@@ -13,17 +11,13 @@ import { checkIsExternalURL } from '@/lib/urls';
export async function NotFoundRefHoverCard(
props: DocumentContextProps & {
children: React.ReactNode;
/** Where the link goes instead, named in the card when it stays on the site. */
fallback?: ResolvedContentRef | null;
}
) {
const {
context: { contentContext },
children,
fallback,
} = props;
const language = contentContext ? await getSpaceLanguage(contentContext) : defaultLanguage;
const destination = fallback && !checkIsExternalURL(fallback.href) ? fallback.text : null;
return (
<HoverCardRoot>
<HoverCardTrigger>{children}</HoverCardTrigger>
@@ -33,20 +27,6 @@ export async function NotFoundRefHoverCard(
<h5 className="font-semibold">{tString(language, 'notfound_title')}</h5>
</div>
<p className="text-sm text-tint">{tString(language, 'notfound_link')}</p>
{destination ? (
<p className="mt-1 flex items-center gap-1.5 text-sm text-tint">
<Icon icon="arrow-right" className="size-3 shrink-0 text-tint-subtle" />
<span>
{t(
language,
'notfound_link_opens',
<span className="font-semibold text-tint-strong">
{destination}
</span>
)}
</span>
</p>
) : null}
</HoverCard>
</HoverCardRoot>
);
@@ -4,6 +4,7 @@ import { usePathname, useSearchParams } from 'next/navigation';
import { useMemo } from 'react';
import type React from 'react';
import { useIsMounted } from '../hooks/useIsMounted';
import { Button, type ButtonProps } from '../primitives/Button';
import { DropdownMenuItem } from '../primitives/DropdownMenu';
import { Link, type LinkInsightsProps, type LinkProps } from '../primitives/Link';
@@ -16,9 +17,17 @@ function useSiteAuthLoginHrefWithLocation(href: string) {
const rawPathname = usePathname();
const searchParams = useSearchParams();
const currentSearch = searchParams?.toString();
const pathname = rawPathname ?? '/';
// On the server, usePathname() returns the internal rewritten route (/sites/…, which includes
// the site API token), so the location is only added once mounted.
// https://nextjs.org/docs/app/api-reference/functions/use-pathname#avoid-hydration-mismatch-with-rewrites
const isMounted = useIsMounted();
const pathname = isMounted ? (rawPathname ?? '/') : null;
return useMemo(() => {
if (pathname === null) {
return href;
}
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const resolved = URL.canParse(href) ? new URL(href) : new URL(href, baseURL);
const siteBasePath = removeTrailingSlash(
@@ -76,7 +76,6 @@ export const ar: TranslationLanguage = {
edit: 'تحرير',
notfound_title: 'الصفحة غير موجودة',
notfound_link: 'يشير هذا الرابط إلى صفحة تمت إزالتها أو لم تعد موجودة.',
notfound_link_opens: 'يفتح: ${1}',
notfound: 'الصفحة التي تبحث عنها غير موجودة.',
notfound_adaptive_title: 'الصفحة غير متاحة',
notfound_adaptive: 'قد تكون هذه الصفحة موجودة، لكن قد تحتاج إلى تسجيل الدخول للوصول إليها.',
@@ -77,7 +77,6 @@ export const bg: TranslationLanguage = {
edit: 'Редактиране',
notfound_title: 'Страницата не е намерена',
notfound_link: 'Тази връзка сочи към страница, която е премахната или вече не съществува.',
notfound_link_opens: 'Отваря: ${1}',
notfound: 'Страницата, която търсите, не съществува.',
notfound_adaptive_title: 'Страницата не е налична',
notfound_adaptive:
@@ -76,7 +76,6 @@ export const cs: TranslationLanguage = {
edit: 'Upravit',
notfound_title: 'Stránka nenalezena',
notfound_link: 'Tento odkaz vede na stránku, která byla odstraněna nebo již neexistuje.',
notfound_link_opens: 'Otevře: ${1}',
notfound: 'Stránka, kterou hledáte, neexistuje.',
notfound_adaptive_title: 'Stránka není dostupná',
notfound_adaptive:
@@ -76,7 +76,6 @@ export const da: TranslationLanguage = {
edit: 'Rediger',
notfound_title: 'Siden blev ikke fundet',
notfound_link: 'Dette link peger på en side, der er blevet fjernet eller ikke længere findes.',
notfound_link_opens: 'Åbner ${1}',
notfound: 'Siden, du leder efter, findes ikke.',
notfound_adaptive_title: 'Siden er ikke tilgængelig',
notfound_adaptive:
@@ -79,7 +79,6 @@ export const de: TranslationLanguage = {
notfound_title: 'Seite nicht gefunden',
notfound_link:
'Dieser Link verweist auf eine Seite, die entfernt wurde oder nicht mehr existiert.',
notfound_link_opens: 'Öffnet ${1}',
notfound: 'Die gesuchte Seite existiert nicht.',
notfound_adaptive_title: 'Seite nicht verfügbar',
notfound_adaptive:
@@ -77,7 +77,6 @@ export const el: TranslationLanguage = {
edit: 'Επεξεργασία',
notfound_title: 'Η σελίδα δεν βρέθηκε',
notfound_link: 'Αυτός ο σύνδεσμος οδηγεί σε μια σελίδα που έχει αφαιρεθεί ή δεν υπάρχει πλέον.',
notfound_link_opens: 'Ανοίγει: ${1}',
notfound: 'Η σελίδα που αναζητάτε δεν υπάρχει.',
notfound_adaptive_title: 'Η σελίδα δεν είναι διαθέσιμη',
notfound_adaptive:
@@ -74,7 +74,6 @@ export const en = {
edit: 'Edit',
notfound_title: 'Page not found',
notfound_link: 'This link points to a page that has been removed or no longer exists.',
notfound_link_opens: 'Opens ${1}',
notfound: "The page you're looking for doesn't exist.",
notfound_adaptive_title: 'Page unavailable',
notfound_adaptive: 'This page may exist, but you may need to log in to access it.',
@@ -78,7 +78,6 @@ export const es: TranslationLanguage = {
edit: 'Editar',
notfound_title: 'Página no encontrada',
notfound_link: 'Este enlace apunta a una página que ha sido eliminada o ya no existe.',
notfound_link_opens: 'Abre ${1}',
notfound: 'La página que buscas no existe.',
notfound_adaptive_title: 'Página no disponible',
notfound_adaptive:
@@ -76,7 +76,6 @@ export const et: TranslationLanguage = {
edit: 'Muuda',
notfound_title: 'Lehte ei leitud',
notfound_link: 'See link viitab lehele, mis on eemaldatud või mida enam ei eksisteeri.',
notfound_link_opens: 'Avab: ${1}',
notfound: 'Lehte, mida otsite, ei eksisteeri.',
notfound_adaptive_title: 'Leht pole saadaval',
notfound_adaptive: 'See leht võib olemas olla, kuid juurdepääsuks võib olla vaja sisse logida.',
@@ -77,7 +77,6 @@ export const fi: TranslationLanguage = {
edit: 'Muokkaa',
notfound_title: 'Sivua ei löytynyt',
notfound_link: 'Tämä linkki osoittaa sivulle, joka on poistettu tai jota ei enää ole.',
notfound_link_opens: 'Avaa: ${1}',
notfound: 'Etsimääsi sivua ei ole olemassa.',
notfound_adaptive_title: 'Sivu ei ole saatavilla',
notfound_adaptive:
@@ -77,7 +77,6 @@ export const fr: TranslationLanguage = {
edit: 'Modifier',
notfound_title: 'Page introuvable',
notfound_link: "Ce lien pointe vers une page qui a été supprimée ou n'existe plus.",
notfound_link_opens: 'Ouvre ${1}',
notfound: 'La page que vous cherchez n’existe pas.',
notfound_adaptive_title: 'Page inaccessible',
notfound_adaptive:
@@ -76,7 +76,6 @@ export const he: TranslationLanguage = {
edit: 'עריכה',
notfound_title: 'הדף לא נמצא',
notfound_link: 'קישור זה מפנה לדף שהוסר או אינו קיים עוד.',
notfound_link_opens: 'פותח: ${1}',
notfound: 'הדף שחיפשת אינו קיים.',
notfound_adaptive_title: 'הדף אינו זמין',
notfound_adaptive: 'ייתכן שהדף קיים, אך ייתכן שעליך להתחבר כדי לגשת אליו.',
@@ -76,7 +76,6 @@ export const hi: TranslationLanguage = {
edit: 'संपादित करें',
notfound_title: 'पृष्ठ नहीं मिला',
notfound_link: 'यह लिंक ऐसे पृष्ठ पर जाता है जिसे हटा दिया गया है या जो अब मौजूद नहीं है।',
notfound_link_opens: '${1} खोलता है',
notfound: 'आप जिस पृष्ठ को खोज रहे हैं वह मौजूद नहीं है।',
notfound_adaptive_title: 'पृष्ठ उपलब्ध नहीं',
notfound_adaptive: 'यह पृष्ठ मौजूद हो सकता है, लेकिन इसे देखने के लिए आपको लॉग इन करना पड़ सकता है।',
@@ -76,7 +76,6 @@ export const hr: TranslationLanguage = {
edit: 'Uredi',
notfound_title: 'Stranica nije pronađena',
notfound_link: 'Ova poveznica vodi na stranicu koja je uklonjena ili više ne postoji.',
notfound_link_opens: 'Otvara: ${1}',
notfound: 'Stranica koju tražite ne postoji.',
notfound_adaptive_title: 'Stranica nije dostupna',
notfound_adaptive:
@@ -77,7 +77,6 @@ export const hu: TranslationLanguage = {
edit: 'Szerkesztés',
notfound_title: 'Az oldal nem található',
notfound_link: 'Ez a hivatkozás egy eltávolított vagy már nem létező oldalra mutat.',
notfound_link_opens: 'Megnyitja: ${1}',
notfound: 'A keresett oldal nem létezik.',
notfound_adaptive_title: 'Az oldal nem érhető el',
notfound_adaptive: 'Ez az oldal létezhet, de a hozzáféréshez lehet, hogy be kell jelentkeznie.',
@@ -76,7 +76,6 @@ export const id: TranslationLanguage = {
edit: 'Edit',
notfound_title: 'Halaman tidak ditemukan',
notfound_link: 'Tautan ini mengarah ke halaman yang telah dihapus atau sudah tidak ada.',
notfound_link_opens: 'Membuka ${1}',
notfound: 'Halaman yang Anda cari tidak ada.',
notfound_adaptive_title: 'Halaman tidak tersedia',
notfound_adaptive:
@@ -78,7 +78,6 @@ export const it: TranslationLanguage = {
edit: 'Modifica',
notfound_title: 'Pagina non trovata',
notfound_link: 'Questo link punta a una pagina che è stata rimossa o non esiste più.',
notfound_link_opens: 'Apre ${1}',
notfound: 'La pagina che cerchi non esiste.',
notfound_adaptive_title: 'Pagina non disponibile',
notfound_adaptive:
@@ -77,7 +77,6 @@ export const ja: TranslationLanguage = {
edit: '編集',
notfound_title: 'ページが見つかりません',
notfound_link: 'このリンクは、削除されたか、もはや存在しないページを指しています。',
notfound_link_opens: '${1} を開きます',
notfound: 'お探しのページは存在しません。',
notfound_adaptive_title: 'ページにアクセスできません',
notfound_adaptive:
@@ -77,7 +77,6 @@ export const ko: TranslationLanguage = {
edit: '수정',
notfound_title: '페이지를 찾을 수 없음',
notfound_link: '이 링크는 삭제되었거나 더 이상 존재하지 않는 페이지를 가리킵니다.',
notfound_link_opens: '${1}(으)로 이동',
notfound: '찾으시는 페이지가 존재하지 않습니다.',
notfound_adaptive_title: '페이지에 접근할 수 없음',
notfound_adaptive: '이 페이지는 존재할 수 있지만, 접근하려면 로그인해야 할 수 있습니다.',
@@ -76,7 +76,6 @@ export const lt: TranslationLanguage = {
edit: 'Redaguoti',
notfound_title: 'Puslapis nerastas',
notfound_link: 'Ši nuoroda veda į puslapį, kuris buvo pašalintas arba nebeegzistuoja.',
notfound_link_opens: 'Atidaro: ${1}',
notfound: 'Puslapis, kurio ieškote, neegzistuoja.',
notfound_adaptive_title: 'Puslapis nepasiekiamas',
notfound_adaptive:
@@ -76,7 +76,6 @@ export const lv: TranslationLanguage = {
edit: 'Rediģēt',
notfound_title: 'Lapa nav atrasta',
notfound_link: 'Šī saite norāda uz lapu, kas ir noņemta vai vairs nepastāv.',
notfound_link_opens: 'Atver: ${1}',
notfound: 'Meklētā lapa nepastāv.',
notfound_adaptive_title: 'Lapa nav pieejama',
notfound_adaptive: 'Šī lapa var pastāvēt, bet, iespējams, jums jāpierakstās, lai tai piekļūtu.',
@@ -76,7 +76,6 @@ export const ms: TranslationLanguage = {
edit: 'Edit',
notfound_title: 'Halaman tidak ditemui',
notfound_link: 'Pautan ini menghala ke halaman yang telah dialih keluar atau tidak lagi wujud.',
notfound_link_opens: 'Membuka ${1}',
notfound: 'Halaman yang anda cari tidak wujud.',
notfound_adaptive_title: 'Halaman tidak tersedia',
notfound_adaptive:
@@ -78,7 +78,6 @@ export const nl: TranslationLanguage = {
edit: 'Bewerken',
notfound_title: 'Pagina niet gevonden',
notfound_link: 'Deze link verwijst naar een pagina die is verwijderd of niet meer bestaat.',
notfound_link_opens: 'Opent ${1}',
notfound: 'De pagina die je zoekt, bestaat niet.',
notfound_adaptive_title: 'Pagina niet beschikbaar',
notfound_adaptive:
@@ -78,7 +78,6 @@ export const no: TranslationLanguage = {
notfound_title: 'Siden ble ikke funnet',
notfound_link:
'Denne lenken peker til en side som har blitt fjernet eller ikke lenger eksisterer.',
notfound_link_opens: 'Åpner ${1}',
notfound: 'Siden du leter etter eksisterer ikke.',
notfound_adaptive_title: 'Siden er ikke tilgjengelig',
notfound_adaptive: 'Denne siden kan finnes, men du må kanskje logge inn for å få tilgang.',
@@ -76,7 +76,6 @@ export const pl: TranslationLanguage = {
edit: 'Edytuj',
notfound_title: 'Nie znaleziono strony',
notfound_link: 'Ten link prowadzi do strony, która została usunięta lub już nie istnieje.',
notfound_link_opens: 'Otwiera: ${1}',
notfound: 'Strona, której szukasz, nie istnieje.',
notfound_adaptive_title: 'Strona niedostępna',
notfound_adaptive: 'Ta strona może istnieć, ale dostęp do niej może wymagać zalogowania.',
@@ -78,7 +78,6 @@ export const pt_br: TranslationLanguage = {
edit: 'Editar',
notfound_title: 'Página não encontrada',
notfound_link: 'Este link aponta para uma página que foi removida ou não existe mais.',
notfound_link_opens: 'Abre ${1}',
notfound: 'A página que você está procurando não existe.',
notfound_adaptive_title: 'Página indisponível',
notfound_adaptive:
@@ -77,7 +77,6 @@ export const pt: TranslationLanguage = {
edit: 'Editar',
notfound_title: 'Página não encontrada',
notfound_link: 'Esta ligação aponta para uma página que foi removida ou já não existe.',
notfound_link_opens: 'Abre ${1}',
notfound: 'A página que procura não existe.',
notfound_adaptive_title: 'Página indisponível',
notfound_adaptive:
@@ -77,7 +77,6 @@ export const ro: TranslationLanguage = {
edit: 'Editează',
notfound_title: 'Pagina nu a fost găsită',
notfound_link: 'Acest link indică o pagină care a fost eliminată sau nu mai există.',
notfound_link_opens: 'Deschide ${1}',
notfound: 'Pagina pe care o cauți nu există.',
notfound_adaptive_title: 'Pagina nu este disponibilă',
notfound_adaptive:
@@ -78,7 +78,6 @@ export const ru: TranslationLanguage = {
edit: 'Редактировать',
notfound_title: 'Страница не найдена',
notfound_link: 'Эта ссылка ведёт на страницу, которая была удалена или больше не существует.',
notfound_link_opens: 'Откроется: ${1}',
notfound: 'Страница, которую вы ищете, не существует.',
notfound_adaptive_title: 'Страница недоступна',
notfound_adaptive:
@@ -77,7 +77,6 @@ export const sk: TranslationLanguage = {
edit: 'Upraviť',
notfound_title: 'Stránka sa nenašla',
notfound_link: 'Tento odkaz smeruje na stránku, ktorá bola odstránená alebo už neexistuje.',
notfound_link_opens: 'Otvorí: ${1}',
notfound: 'Stránka, ktorú hľadáte, neexistuje.',
notfound_adaptive_title: 'Stránka nie je dostupná',
notfound_adaptive:
@@ -77,7 +77,6 @@ export const sl: TranslationLanguage = {
edit: 'Uredi',
notfound_title: 'Strani ni bilo mogoče najti',
notfound_link: 'Ta povezava kaže na stran, ki je bila odstranjena ali ne obstaja več.',
notfound_link_opens: 'Odpre: ${1}',
notfound: 'Stran, ki jo iščete, ne obstaja.',
notfound_adaptive_title: 'Stran ni na voljo',
notfound_adaptive: 'Ta stran morda obstaja, vendar se boste za dostop morda morali prijaviti.',
@@ -76,7 +76,6 @@ export const sv: TranslationLanguage = {
edit: 'Redigera',
notfound_title: 'Sidan hittades inte',
notfound_link: 'Den här länken pekar på en sida som har tagits bort eller inte längre finns.',
notfound_link_opens: 'Öppnar ${1}',
notfound: 'Sidan du letar efter finns inte.',
notfound_adaptive_title: 'Sidan är inte tillgänglig',
notfound_adaptive: 'Sidan kan finnas, men du kan behöva logga in för att få åtkomst.',
@@ -75,7 +75,6 @@ export const th: TranslationLanguage = {
edit: 'แก้ไข',
notfound_title: 'ไม่พบหน้า',
notfound_link: 'ลิงก์นี้ชี้ไปยังหน้าที่ถูกลบหรือไม่มีอยู่อีกต่อไป',
notfound_link_opens: 'เปิด ${1}',
notfound: 'หน้าที่คุณกำลังค้นหาไม่มีอยู่',
notfound_adaptive_title: 'หน้าไม่พร้อมใช้งาน',
notfound_adaptive: 'หน้านี้อาจมีอยู่ แต่คุณอาจต้องเข้าสู่ระบบเพื่อเข้าถึง',
@@ -76,7 +76,6 @@ export const tr: TranslationLanguage = {
edit: 'Düzenle',
notfound_title: 'Sayfa bulunamadı',
notfound_link: 'Bu bağlantı kaldırılmış veya artık mevcut olmayan bir sayfaya işaret ediyor.',
notfound_link_opens: '${1} açılır',
notfound: 'Aradığınız sayfa mevcut değil.',
notfound_adaptive_title: 'Sayfa kullanılamıyor',
notfound_adaptive: 'Bu sayfa mevcut olabilir, ancak erişmek için oturum açmanız gerekebilir.',
@@ -76,7 +76,6 @@ export const uk: TranslationLanguage = {
edit: 'Редагувати',
notfound_title: 'Сторінку не знайдено',
notfound_link: 'Це посилання веде на сторінку, яку видалено або якої більше не існує.',
notfound_link_opens: 'Відкриється: ${1}',
notfound: 'Сторінка, яку ви шукаєте, не існує.',
notfound_adaptive_title: 'Сторінка недоступна',
notfound_adaptive: 'Ця сторінка може існувати, але для доступу до неї може знадобитися вхід.',
@@ -76,7 +76,6 @@ export const vi: TranslationLanguage = {
edit: 'Chỉnh sửa',
notfound_title: 'Không tìm thấy trang',
notfound_link: 'Liên kết này trỏ đến một trang đã bị xóa hoặc không còn tồn tại.',
notfound_link_opens: 'Mở ${1}',
notfound: 'Trang bạn đang tìm kiếm không tồn tại.',
notfound_adaptive_title: 'Trang không khả dụng',
notfound_adaptive: 'Trang này có thể tồn tại, nhưng bạn có thể cần đăng nhập để truy cập.',
@@ -74,7 +74,6 @@ export const yue: TranslationLanguage = {
edit: '編輯',
notfound_title: '搵唔到頁面',
notfound_link: '呢條連結指向嘅頁面已被移除或已經唔存在。',
notfound_link_opens: '開啟 ${1}',
notfound: '你搵緊嘅頁面唔存在。',
notfound_adaptive_title: '頁面無法使用',
notfound_adaptive: '呢個頁面可能存在,但你可能需要登入先可以存取。',
@@ -74,7 +74,6 @@ export const zh_tw: TranslationLanguage = {
edit: '編輯',
notfound_title: '找不到頁面',
notfound_link: '此連結指向的頁面已被移除或不再存在。',
notfound_link_opens: '開啟 ${1}',
notfound: '您要尋找的頁面不存在。',
notfound_adaptive_title: '頁面無法使用',
notfound_adaptive: '此頁面可能存在,但您可能需要登入才能存取。',
@@ -75,7 +75,6 @@ export const zh: TranslationLanguage = {
edit: '编辑',
notfound_title: '页面未找到',
notfound_link: '此链接指向已被删除或不再存在的页面。',
notfound_link_opens: '打开 ${1}',
notfound: '您要找的页面不存在。',
notfound_adaptive_title: '页面无法访问',
notfound_adaptive: '该页面可能存在,但您可能需要登录后才能访问。',
+113 -65
View File
@@ -2,11 +2,7 @@ import { describe, expect, it } from 'bun:test';
import type { Revision, RevisionPageDocument, SiteSpace, Space } from '@gitbook/api';
import {
resolveContentRef,
resolveContentRefFallback,
resolveStringContentRef,
} from './references';
import { resolveContentRef, resolveStringContentRef } from './references';
import type { GitBookAnyContext } from '@/lib/context';
import type { GitBookDataFetcher } from '@/lib/data';
import { createLinker } from '@/lib/links';
@@ -743,78 +739,130 @@ describe('resolveContentRef for direct space links', () => {
});
});
describe('resolveContentRef for application URLs into the site', () => {
const alerts = {
object: 'space',
id: 'space-alerts',
title: 'Alerts',
urls: {
app: 'https://app.gitbook.com/o/org/s/space-alerts/',
published: 'https://docs.example.com/analytics-alerts/',
},
} as unknown as Space;
const alertsSiteSpace = {
object: 'site-space',
id: 'site-space-alerts',
path: 'analytics-alerts',
space: alerts,
title: 'Alerts',
urls: { published: 'https://docs.example.com/analytics-alerts/' },
} as unknown as SiteSpace;
describe('resolveContentRef for a page ref whose page was re-created', () => {
function buildPage(id: string, title: string, path: string): RevisionPageDocument {
return {
object: 'page',
id,
type: 'document',
kind: 'sheet',
title,
path,
slug: path.split('/').pop(),
pages: [],
tags: [],
layout: {},
urls: { app: 'https://app.gitbook.com/page' },
} as unknown as RevisionPageDocument;
}
const context = {
linker: createLinker({ host: 'docs.example.com', spaceBasePath: '/', siteBasePath: '/' }),
space: { id: 'space-notes' },
site: { object: 'site', id: 'site-1' },
sections: null,
structure: { type: 'siteSpaces', structure: [alertsSiteSpace] },
} as unknown as GitBookAnyContext;
function buildRevision(id: string, pages: RevisionPageDocument[]): Revision {
return {
object: 'revision',
id,
type: 'edits',
pages,
files: [],
reusableContents: [],
tags: [],
parents: [],
createdAt: '',
urls: { app: '' },
} as unknown as Revision;
}
it('keeps the page path and anchor of an application URL into a site space', async () => {
const result = await resolveContentRef(
{
kind: 'url',
url: 'https://app.gitbook.com/o/org/s/space-alerts/alerts-by-name/amsi-bypass#rules',
function buildSpace(id: string): Space {
return {
object: 'space',
id,
title: id,
organization: 'org',
revision: `rev-${id}`,
urls: {
location: `https://api.gitbook.com/spaces/${id}`,
app: `https://app.gitbook.com/o/org/s/${id}/`,
published: `https://${id}.gitbook.io/`,
},
context
} as unknown as Space;
}
const recreated = buildPage('page-new', 'Detector', 'alerts-by-name/detector');
const other = buildPage('page-other', 'Other', 'alerts-by-name/other');
const alertsRevision = buildRevision('rev-alerts', [recreated, other]);
const alerts = buildSpace('alerts');
const notes = buildSpace('notes');
const dataFetcher = {
getSpace: async ({ spaceId }: { spaceId: string }) =>
spaceId === 'alerts'
? { data: alerts }
: { error: { code: 404, message: 'Not found' } },
getRevision: async ({ spaceId }: { spaceId: string }) =>
spaceId === 'alerts'
? { data: alertsRevision }
: { error: { code: 404, message: 'Not found' } },
getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }),
withToken: function () {
return this;
},
} as unknown as GitBookDataFetcher;
function buildContext(space: Space, revision: Revision): GitBookAnyContext {
return {
dataFetcher,
linker: createLinker({
host: 'docs.example.com',
spaceBasePath: '/',
siteBasePath: '/',
}),
organizationId: 'org',
space,
revision,
revisionId: revision.id,
changeRequest: null,
shareKey: undefined,
} as unknown as GitBookAnyContext;
}
// Refs only get a typed `path` once @gitbook/api ships it, so the tests add it through a cast.
const withPath = (ref: object, path: string) => ({ ...ref, path }) as never;
it('finds the page at the recorded path when its ID no longer exists', async () => {
const result = await resolveContentRef(
withPath({ kind: 'page', page: 'page-deleted' }, 'alerts-by-name/detector'),
buildContext(alerts, alertsRevision)
);
expect(result?.href).toBe('/analytics-alerts/alerts-by-name/amsi-bypass#rules');
expect(result?.text).toBe('Detector');
});
it('leaves an application URL into a space outside the site untouched', async () => {
const url = 'https://app.gitbook.com/o/org/s/space-elsewhere/alerts-by-name/amsi-bypass';
const result = await resolveContentRef({ kind: 'url', url }, context);
expect(result?.href).toBe(url);
});
it('leaves an application URL into a change request untouched', async () => {
const url = 'https://app.gitbook.com/o/org/s/space-alerts/~/changes/1/alerts-by-name';
const result = await resolveContentRef({ kind: 'url', url }, context);
expect(result?.href).toBe(url);
});
it('falls back to the site space, not the application, for a page ref that no longer resolves', () => {
const fallback = resolveContentRefFallback(
{ kind: 'page', space: 'space-alerts', page: 'page-deleted' },
context
it('finds the page at the recorded path in another space', async () => {
const result = await resolveContentRef(
withPath(
{ kind: 'page', space: 'alerts', page: 'page-deleted' },
'alerts-by-name/detector'
),
buildContext(notes, buildRevision('rev-notes', []))
);
expect(fallback?.href).toBe('/analytics-alerts');
expect(fallback?.text).toBe('Alerts');
expect(result?.text).toBe('Detector');
});
it('keeps the application fallback for a page ref into a space outside the site', () => {
const fallback = resolveContentRefFallback(
{ kind: 'page', space: 'space-elsewhere', page: 'page-deleted' },
context
it('keeps resolving by ID while the page still exists', async () => {
const result = await resolveContentRef(
withPath({ kind: 'page', page: 'page-other' }, 'alerts-by-name/detector'),
buildContext(alerts, alertsRevision)
);
expect(fallback?.href).toBe('https://app.gitbook.com/s/space-elsewhere');
expect(fallback?.text).toBe('space');
expect(result?.text).toBe('Other');
});
it('does not resolve when neither the ID nor the recorded path exists', async () => {
const result = await resolveContentRef(
withPath({ kind: 'page', page: 'page-deleted' }, 'alerts-by-name/removed'),
buildContext(alerts, alertsRevision)
);
expect(result).toBeNull();
});
});
+20 -61
View File
@@ -4,6 +4,7 @@ import type React from 'react';
import type {
ContentRef,
JSONDocument,
Revision,
RevisionFile,
RevisionPageDocument,
RevisionReusableContent,
@@ -16,7 +17,7 @@ import type { Filesystem } from '@gitbook/openapi-parser';
import { getGitBookAppHref } from './app';
import { getBlockById, getBlockTitle } from './document';
import { resolvePageId } from './pages';
import { resolvePageId, resolvePagePath } from './pages';
import {
findSiteSpaceBy,
getFallbackSiteSpacePath,
@@ -40,16 +41,8 @@ import {
getRevisionReusableContent,
ignoreDataThrownError,
} from '@/lib/data';
import { GITBOOK_APP_URL } from '@/lib/env';
import { type GitBookLinker, createLinker, linkerWithAbsoluteURLs } from '@/lib/links';
/**
* Path of an application URL to a space's content: `/o/:org/s/:space/:pagePath`, optionally under
* `/sites/:site`. A path under `~/` (a change request or revision) is not published content.
*/
const APP_SPACE_CONTENT_PATH =
/^(?:\/o\/[^/]+)?(?:\/sites\/[^/]+)?\/s\/([^/]+)(?:\/(?!~)(.*?))?\/?$/;
export interface ResolvedContentRef {
/** Text to render in the content ref */
text: string;
@@ -151,10 +144,9 @@ export async function resolveContentRef(
switch (contentRef.kind) {
case 'url': {
const href = resolveAppURLInSite(contentRef.url, context) ?? contentRef.url;
return {
href,
text: href,
href: contentRef.url,
text: contentRef.url,
active: false,
};
}
@@ -201,7 +193,8 @@ export async function resolveContentRef(
? activePage
? { page: activePage, ancestors: [] }
: undefined
: resolvePageId(revision.pages, contentRef.page);
: (resolvePageId(revision.pages, contentRef.page) ??
resolvePageAtRecordedPath(revision, contentRef));
const page = resolvePageResult?.page;
const ancestors =
@@ -428,22 +421,28 @@ export function isContentRefInDifferentSpace<Ref extends ContentRef>(
* Called if we can't resolve the content ref to have a potential fallback to display to the
* user instead of not found.
*/
export function resolveContentRefFallback(
contentRef: ContentRef,
context: GitBookAnyContext | undefined
): ResolvedContentRef | null {
export function resolveContentRefFallback(contentRef: ContentRef): ResolvedContentRef | null {
if ('space' in contentRef && contentRef.space) {
const linker = context ? getLinkerForSpaceInSite(context, contentRef.space) : null;
const inSite = context ? getBestTargetSpaceFromSite(context, contentRef.space) : undefined;
return {
href: linker?.toPathInSpace('') ?? getGitBookAppHref(`/s/${contentRef.space}`),
text: linker && inSite ? getSpaceRefText(inSite, context?.locale) : 'space',
href: getGitBookAppHref(`/s/${contentRef.space}`),
text: 'space',
active: false,
};
}
return null;
}
/**
* Find the page at the path recorded on a page or anchor ref, for when its page ID no longer
* exists (the page was deleted and re-created at the same path).
*/
function resolvePageAtRecordedPath(revision: Revision, contentRef: ContentRef) {
// TODO: read `contentRef.path` directly once @gitbook/api ships it on ContentRefPage and
// ContentRefAnchor; until then the field is untyped.
const { path } = contentRef as { path?: unknown };
return typeof path === 'string' && path ? resolvePagePath(revision.pages, path) : undefined;
}
/**
* This function is used to get the best possible target space while resolving a content ref.
* It will try to return the space in the site context if it exists to avoid cross-site links.
@@ -474,46 +473,6 @@ async function getBestTargetSpace(
return fetchedSpace ? { space: fetchedSpace, siteSpace: null, siteSection: null } : undefined;
}
/**
* Map an application URL into a space of the current site to the same page path on the site, so
* a link that was never resolved to a page doesn't send visitors to the app. The page isn't looked
* up: one that doesn't exist lands on the site's not-found page.
*/
function resolveAppURLInSite(url: string, context: GitBookAnyContext): string | null {
if (!URL.canParse(url)) {
return null;
}
const parsed = new URL(url);
if (parsed.origin !== new URL(GITBOOK_APP_URL).origin) {
return null;
}
const match = parsed.pathname.match(APP_SPACE_CONTENT_PATH);
const linker = match?.[1] ? getLinkerForSpaceInSite(context, match[1]) : null;
if (!linker) {
return null;
}
return linker.toPathForPagePath({
path: match?.[2] ?? '',
anchor: parsed.hash.slice(1) || undefined,
});
}
/**
* Linker for a space that is part of the current site, or null when it isn't.
*/
function getLinkerForSpaceInSite(
context: GitBookAnyContext,
spaceId: string
): GitBookLinker | null {
const target = getBestTargetSpaceFromSite(context, spaceId);
if (!target?.siteSpace || !('site' in context)) {
return null;
}
return context.linker.withOtherSiteSpace({
spaceBasePath: getFallbackSiteSpacePath(context, target.siteSpace),
});
}
/**
* Find the best target space for a content ref, from the current site.
*/