also looking at this
fix: preserve deployment paths in local search results
#91
5 files
+81
-23
| @@ -362,6 +362,14 @@ generated pages. Skip it when you do not need search in a given build: | ||
| 362 | 362 | bunpress build --no-search-index |
| 363 | 363 | ``` |
| 364 | 364 | |
| 365 | ## Deployment under a path | |
| 366 | ||
| 367 | Local search results use the same deployment path as rendered pages. With | |
| 368 | `basePath: '/docs'`, a result for `guide.md` opens `/docs/guide`; section anchors | |
| 369 | and locale paths retain that prefix. When no explicit base path is set, the | |
| 370 | pathname of `sitemap.baseUrl` or `url` supplies it, matching the page renderer. | |
| 371 | Root-hosted sites retain their existing paths. | |
| 372 | ||
| 365 | 373 | ## Accessibility |
| 366 | 374 | |
| 367 | 375 | Search is accessible by default: |
| @@ -2,6 +2,7 @@ import type { BunPressConfig, LocalSearchOptions, SearchConfig } from './types' | ||
| 2 | 2 | import { Glob, YAML } from 'bun' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { localizeUrl, resolveI18nConfig } from './i18n' |
| 5 | import { getConfiguredBasePath } from './site-url' | |
| 5 | 6 | import { createFenceTracker, stripFencedCode } from './markdown-fences' |
| 6 | 7 | import { unwrapMessageFences } from './message-previews' |
| 7 | 8 | import { generateSlug, markdownHeadingText, reserveUniqueSlug } from './toc' |
| @@ -473,5 +474,12 @@ export async function buildSearchIndex(docsDir: string, config?: BunPressConfig) | ||
| 473 | 474 | } |
| 474 | 475 | } |
| 475 | 476 | |
| 477 | // Apply the deployment prefix after locale fallback has compared page keys. | |
| 478 | // Every record is still docs-relative here, including a real `docs/` directory. | |
| 479 | const basePath = config ? getConfiguredBasePath(config) : '' | |
| 480 | if (basePath) { | |
| 481 | for (const record of records) record.url = `${basePath}${record.url}` | |
| 482 | } | |
| 483 | ||
| 476 | 484 | return applyStoreFields(records, options) |
| 477 | 485 | } |
| @@ -29,7 +29,9 @@ import { clearTemplateCache, render } from './template-loader' | ||
| 29 | 29 | import { messagePreviewCSS, renderMessagePreview } from './message-previews' |
| 30 | 30 | import { getThemeCSS } from './themes' |
| 31 | 31 | import { buildTocHierarchy, defaultTocConfig, extractHeadings, filterHeadings, generateInlineTocHtml, generateSlug, markdownHeadingText, reserveUniqueSlug } from './toc' |
| 32 | import { siteUrl } from './site-url' | |
| 32 | import { getConfiguredBasePath, siteUrl } from './site-url' | |
| 33 | ||
| 34 | export { getConfiguredBasePath } from './site-url' | |
| 33 | 35 | import { decodeHtmlEntities, escapeHtmlAttribute } from './html-entities' |
| 34 | 36 | |
| 35 | 37 | export { decodeHtmlEntities } from './html-entities' |
| @@ -901,28 +903,6 @@ function generateNavScreen(config: BunPressConfig, currentPath: string = ''): st | ||
| 901 | 903 | </div>` |
| 902 | 904 | } |
| 903 | 905 | |
| 904 | export function getConfiguredBasePath(config: BunPressConfig): string { | |
| 905 | if (config.basePath) { | |
| 906 | const normalized = config.basePath.trim().replace(/\/+$/, '') | |
| 907 | if (!normalized || normalized === '/') | |
| 908 | return '' | |
| 909 | return normalized.startsWith('/') ? normalized : `/${normalized}` | |
| 910 | } | |
| 911 | ||
| 912 | const baseUrl = siteUrl(config) | |
| 913 | if (!baseUrl) { | |
| 914 | return '' | |
| 915 | } | |
| 916 | ||
| 917 | try { | |
| 918 | const pathname = new URL(baseUrl).pathname.replace(/\/+$/, '') | |
| 919 | return pathname === '/' ? '' : pathname | |
| 920 | } | |
| 921 | catch { | |
| 922 | return '' | |
| 923 | } | |
| 924 | } | |
| 925 | ||
| 926 | 906 | export function stripConfiguredBasePath(config: BunPressConfig, pathname: string): string { |
| 927 | 907 | const basePath = getConfiguredBasePath(config) |
| 928 | 908 | if (!basePath) |
| @@ -17,3 +17,26 @@ import type { BunPressConfig } from './types' | ||
| 17 | 17 | export function siteUrl(config: BunPressConfig): string { |
| 18 | 18 | return (config.sitemap?.baseUrl || config.url || '').replace(/\/$/, '') |
| 19 | 19 | } |
| 20 | ||
| 21 | /** Shared deployment prefix for rendered pages and generated navigation data. */ | |
| 22 | export function getConfiguredBasePath(config: BunPressConfig): string { | |
| 23 | if (config.basePath) { | |
| 24 | const normalized = config.basePath.trim().replace(/\/+$/, '') | |
| 25 | if (!normalized || normalized === '/') | |
| 26 | return '' | |
| 27 | return normalized.startsWith('/') ? normalized : `/${normalized}` | |
| 28 | } | |
| 29 | ||
| 30 | const baseUrl = siteUrl(config) | |
| 31 | if (!baseUrl) { | |
| 32 | return '' | |
| 33 | } | |
| 34 | ||
| 35 | try { | |
| 36 | const pathname = new URL(baseUrl).pathname.replace(/\/+$/, '') | |
| 37 | return pathname === '/' ? '' : pathname | |
| 38 | } | |
| 39 | catch { | |
| 40 | return '' | |
| 41 | } | |
| 42 | } | |
| @@ -0,0 +1,39 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, it } from 'bun:test' | |
| 2 | import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { buildSearchIndex } from '../packages/bunpress/src/search-index' | |
| 6 | import type { BunPressConfig } from '../packages/bunpress/src/types' | |
| 7 | ||
| 8 | let root: string | |
| 9 | beforeAll(async () => { | |
| 10 | root = await mkdtemp(join(tmpdir(), 'bunpress-search-base-')) | |
| 11 | await mkdir(join(root, 'docs')) | |
| 12 | await writeFile(join(root, 'index.md'), '# Home\n\nStart here.\n') | |
| 13 | await writeFile(join(root, 'guide.md'), '# Guide\n\nLead.\n\n## Details\n\nDetails.\n') | |
| 14 | await writeFile(join(root, 'docs/repeat.md'), '# Repeat\n\nA real nested docs directory.\n') | |
| 15 | }) | |
| 16 | afterAll(async () => { if (root) await rm(root, { recursive: true, force: true }) }) | |
| 17 | ||
| 18 | describe('search result deployment paths', () => { | |
| 19 | const urls = async (config: Partial<BunPressConfig>) => (await buildSearchIndex(root, { verbose: false, ...config } as BunPressConfig)).map(record => record.url) | |
| 20 | it('keeps page, home and anchored results under an explicit base path', async () => { | |
| 21 | expect(await urls({ basePath: '/docs' })).toEqual(['/docs/docs/repeat', '/docs/guide', '/docs/guide#details', '/docs/']) | |
| 22 | }) | |
| 23 | it('normalizes a nested deployment path using the renderer contract', async () => { | |
| 24 | expect(await urls({ basePath: ' manual/reference/// ' })).toEqual(['/manual/reference/docs/repeat', '/manual/reference/guide', '/manual/reference/guide#details', '/manual/reference/']) | |
| 25 | }) | |
| 26 | it('uses the configured site URL path when no explicit base path exists', async () => { | |
| 27 | expect(await urls({ sitemap: { baseUrl: 'https://example.test/help/docs/' } })).toEqual(['/help/docs/docs/repeat', '/help/docs/guide', '/help/docs/guide#details', '/help/docs/']) | |
| 28 | }) | |
| 29 | it('prefixes locale fallback only after translation selection', async () => { | |
| 30 | const index = await buildSearchIndex(root, { verbose: false, basePath: '/docs', i18n: { locales: ['en', 'es'], defaultLocale: 'en' } } as BunPressConfig) | |
| 31 | expect(index.filter(record => record.locale === 'en').map(record => record.url)).toEqual(['/docs/docs/repeat', '/docs/guide', '/docs/guide#details', '/docs/']) | |
| 32 | expect(index.filter(record => record.locale === 'es').map(record => record.url)).toEqual(['/docs/es/docs/repeat', '/docs/es/guide', '/docs/es/guide#details', '/docs/es']) | |
| 33 | }) | |
| 34 | it('preserves root deployments and an explicit root override', async () => { | |
| 35 | const expected = ['/docs/repeat', '/guide', '/guide#details', '/'] | |
| 36 | expect(await urls({})).toEqual(expected) | |
| 37 | expect(await urls({ basePath: '/', sitemap: { baseUrl: 'https://example.test/help' } })).toEqual(expected) | |
| 38 | }) | |
| 39 | }) | |