diff --git a/.changeset/section-hover-link-name.md b/.changeset/section-hover-link-name.md new file mode 100644 index 000000000..51b095be2 --- /dev/null +++ b/.changeset/section-hover-link-name.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Show the containing section name when hovering a direct link to a space. diff --git a/packages/gitbook/src/lib/references.test.ts b/packages/gitbook/src/lib/references.test.ts index 407c32b0a..177296e76 100644 --- a/packages/gitbook/src/lib/references.test.ts +++ b/packages/gitbook/src/lib/references.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; -import type { Revision, RevisionPageDocument, Space } from '@gitbook/api'; +import type { Revision, RevisionPageDocument, SiteSpace, Space } from '@gitbook/api'; import { resolveContentRef, resolveStringContentRef } from './references'; import type { GitBookAnyContext } from '@/lib/context'; @@ -267,3 +267,182 @@ describe('resolveContentRef', () => { expect(result?.text).toBe('Space B Page'); }); }); + +describe('resolveContentRef for direct space links', () => { + function buildSpace(id: string, title: string): Space { + return { + object: 'space', + id, + title, + 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/`, + }, + } as unknown as Space; + } + + function buildSiteSpace(space: Space, title: string): SiteSpace { + return { + object: 'site-space', + id: `site-${space.id}`, + path: '', + space, + title, + draft: false, + urls: { published: `https://site.gitbook.io/${space.id}/` }, + } as unknown as SiteSpace; + } + + function buildContext(overrides: Record): GitBookAnyContext { + const currentSpace = buildSpace('space-current', 'Current Space'); + + const dataFetcher = { + getSpace: async () => ({ error: { code: 404, message: 'Not found' } }), + getRevision: async () => ({ error: { code: 404, message: 'Not found' } }), + getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }), + withToken: function () { + return this; + }, + } as unknown as GitBookDataFetcher; + + return { + dataFetcher, + linker: createLinker({ + host: 'docs.example.com', + spaceBasePath: '/space-current/', + siteBasePath: '/', + }), + organizationId: 'org', + space: currentSpace, + revision: { + object: 'revision', + id: 'rev-space-current', + pages: [], + files: [], + reusableContents: [], + }, + revisionId: 'rev-space-current', + changeRequest: null, + shareKey: undefined, + site: { object: 'site', id: 'site-1' }, + ...overrides, + } as unknown as GitBookAnyContext; + } + + it('returns the containing section title, not the site-space title', async () => { + const targetSpace = buildSpace('space-target', 'Target Space Title'); + const siteSpace = buildSiteSpace(targetSpace, 'Variant Title'); + const section = { + object: 'site-section', + id: 'section-1', + title: 'Reference', + draft: false, + path: '', + siteSpaces: [siteSpace], + urls: {}, + }; + + const context = buildContext({ + siteSpace, + sections: { list: [section], current: section }, + structure: { type: 'sections', structure: [section] }, + }); + + const result = await resolveContentRef({ kind: 'space', space: 'space-current' }, context); + + expect(result?.text).toBe('Reference'); + expect(result?.href).toBe(siteSpace.urls.published!); + expect(result?.active).toBe(true); + }); + + it('returns the localized section title when available', async () => { + const targetSpace = buildSpace('space-target', 'Target Space Title'); + const siteSpace = buildSiteSpace(targetSpace, 'Variant Title'); + const section = { + object: 'site-section', + id: 'section-1', + title: 'Reference', + localizedTitle: { fr: 'Référence' }, + draft: false, + path: '', + siteSpaces: [siteSpace], + urls: {}, + }; + + const contextFr = buildContext({ + siteSpace, + sections: { list: [section], current: section }, + structure: { type: 'sections', structure: [section] }, + locale: 'fr', + }); + + const resultFr = await resolveContentRef( + { kind: 'space', space: 'space-current' }, + contextFr + ); + expect(resultFr?.text).toBe('Référence'); + + const contextEn = buildContext({ + siteSpace, + sections: { list: [section], current: section }, + structure: { type: 'sections', structure: [section] }, + locale: 'en', + }); + + const resultEn = await resolveContentRef( + { kind: 'space', space: 'space-current' }, + contextEn + ); + expect(resultEn?.text).toBe('Reference'); + }); + + it('falls back to the site-space title when the site has no sections', async () => { + const targetSpace = buildSpace('space-target', 'Target Space Title'); + const siteSpace = buildSiteSpace(targetSpace, 'Variant Title'); + + const context = buildContext({ + siteSpace, + sections: null, + structure: { type: 'siteSpaces', structure: [siteSpace] }, + }); + + const result = await resolveContentRef({ kind: 'space', space: 'space-current' }, context); + + expect(result?.text).toBe('Variant Title'); + }); + + it('falls back to the raw space title for an external/unmapped space', async () => { + const externalSpace = buildSpace('space-external', 'External Space Title'); + + const dataFetcher = { + getSpace: async ({ spaceId }: { spaceId: string }) => { + if (spaceId === 'space-external') return { data: externalSpace }; + return { error: { code: 404, message: 'Not found' } }; + }, + getRevision: async () => ({ error: { code: 404, message: 'Not found' } }), + getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }), + withToken: function () { + return this; + }, + } as unknown as GitBookDataFetcher; + + const context = buildContext({ + siteSpace: buildSiteSpace( + buildSpace('space-current', 'Current Space'), + 'Current Variant' + ), + sections: null, + structure: { type: 'siteSpaces', structure: [] }, + dataFetcher, + }); + + const result = await resolveContentRef({ kind: 'space', space: 'space-external' }, context); + + expect(result?.text).toBe('External Space Title'); + expect(result?.href).toBe(externalSpace.urls.published!); + expect(result?.active).toBe(false); + }); +}); diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index bf62589e9..9912a5eab 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -7,8 +7,10 @@ import type { RevisionFile, RevisionPageDocument, RevisionReusableContent, + SiteSection, SiteSpace, Space, + TranslationLanguage, } from '@gitbook/api'; import type { Filesystem } from '@gitbook/openapi-parser'; @@ -266,6 +268,8 @@ export async function resolveContentRef( ? { space: context.space, siteSpace: 'siteSpace' in context ? context.siteSpace : null, + siteSection: + 'sections' in context ? (context.sections?.current ?? null) : null, } : await getBestTargetSpace(context, contentRef.space); @@ -278,7 +282,7 @@ export async function resolveContentRef( targetSpace.siteSpace?.urls.published ?? targetSpace.space.urls.published ?? targetSpace.space.urls.app, - text: targetSpace.siteSpace?.title ?? targetSpace.space.title, + text: getSpaceRefText(targetSpace, context.locale), active: contentRef.space === space.id, }; } @@ -430,7 +434,9 @@ export function resolveContentRefFallback(contentRef: ContentRef): ResolvedConte async function getBestTargetSpace( context: GitBookAnyContext, spaceId: string -): Promise<{ space: Space; siteSpace: SiteSpace | null } | undefined> { +): Promise< + { space: Space; siteSpace: SiteSpace | null; siteSection: SiteSection | null } | undefined +> { // In the context of sites, we try to find our target space in the site structure. // because the url of this space will be in the same site. const inSite = getBestTargetSpaceFromSite(context, spaceId); @@ -448,7 +454,7 @@ async function getBestTargetSpace( ); // Else we try return the fetched space from the API. - return fetchedSpace ? { space: fetchedSpace, siteSpace: null } : undefined; + return fetchedSpace ? { space: fetchedSpace, siteSpace: null, siteSection: null } : undefined; } /** @@ -457,20 +463,42 @@ async function getBestTargetSpace( function getBestTargetSpaceFromSite( context: GitBookAnyContext, spaceId: string -): { space: Space; siteSpace: SiteSpace | null } | undefined { +): { space: Space; siteSpace: SiteSpace | null; siteSection: SiteSection | null } | undefined { if ('site' in context) { const found = findSiteSpaceBy( context.structure, (siteSpace) => siteSpace.space.id === spaceId ); if (found) { - return { space: found.siteSpace.space, siteSpace: found.siteSpace }; + return { + space: found.siteSpace.space, + siteSpace: found.siteSpace, + siteSection: found.siteSection, + }; } } return undefined; } +/** + * Resolve the text to show for a direct link to a space: the containing section's + * title takes precedence, since that's what organizes the site's navigation for the + * reader, then the site-space (variant) title, then the raw space title. + */ +function getSpaceRefText( + targetSpace: { space: Space; siteSpace: SiteSpace | null; siteSection: SiteSection | null }, + currentLanguage: TranslationLanguage | undefined +): string { + if (targetSpace.siteSection) { + return getLocalizedTitle(targetSpace.siteSection, currentLanguage); + } + if (targetSpace.siteSpace) { + return getLocalizedTitle(targetSpace.siteSpace, currentLanguage); + } + return targetSpace.space.title; +} + async function resolveContentRefInSpace( spaceId: string, context: GitBookAnyContext,