ReviewOS

also looking at this

stacks/bunpress

fix: preserve deployment paths in local search results

#91
Merged chris wants to merge codex/search-base-path into main
5 files +81 -23
docs/features/search.mdmodified+8-0
Changes to docs/features/search.md
@@ -362,6 +362,14 @@ generated pages. Skip it when you do not need search in a given build:
362362bunpress build --no-search-index
363363```
364364
365## Deployment under a path
366
367Local search results use the same deployment path as rendered pages. With
368`basePath: '/docs'`, a result for `guide.md` opens `/docs/guide`; section anchors
369and locale paths retain that prefix. When no explicit base path is set, the
370pathname of `sitemap.baseUrl` or `url` supplies it, matching the page renderer.
371Root-hosted sites retain their existing paths.
372
365373## Accessibility
366374
367375Search is accessible by default:
packages/bunpress/src/search-index.tsmodified+8-0
Changes to packages/bunpress/src/search-index.ts
@@ -2,6 +2,7 @@ import type { BunPressConfig, LocalSearchOptions, SearchConfig } from './types'
22import { Glob, YAML } from 'bun'
33import { join } from 'node:path'
44import { localizeUrl, resolveI18nConfig } from './i18n'
5import { getConfiguredBasePath } from './site-url'
56import { createFenceTracker, stripFencedCode } from './markdown-fences'
67import { unwrapMessageFences } from './message-previews'
78import { generateSlug, markdownHeadingText, reserveUniqueSlug } from './toc'
@@ -473,5 +474,12 @@ export async function buildSearchIndex(docsDir: string, config?: BunPressConfig)
473474 }
474475 }
475476
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
476484 return applyStoreFields(records, options)
477485}
packages/bunpress/src/serve.tsmodified+3-23
Changes to packages/bunpress/src/serve.ts
@@ -29,7 +29,9 @@ import { clearTemplateCache, render } from './template-loader'
2929import { messagePreviewCSS, renderMessagePreview } from './message-previews'
3030import { getThemeCSS } from './themes'
3131import { buildTocHierarchy, defaultTocConfig, extractHeadings, filterHeadings, generateInlineTocHtml, generateSlug, markdownHeadingText, reserveUniqueSlug } from './toc'
32import { siteUrl } from './site-url'
32import { getConfiguredBasePath, siteUrl } from './site-url'
33
34export { getConfiguredBasePath } from './site-url'
3335import { decodeHtmlEntities, escapeHtmlAttribute } from './html-entities'
3436
3537export { decodeHtmlEntities } from './html-entities'
@@ -901,28 +903,6 @@ function generateNavScreen(config: BunPressConfig, currentPath: string = ''): st
901903 </div>`
902904}
903905
904export 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
926906export function stripConfiguredBasePath(config: BunPressConfig, pathname: string): string {
927907 const basePath = getConfiguredBasePath(config)
928908 if (!basePath)
packages/bunpress/src/site-url.tsmodified+23-0
Changes to packages/bunpress/src/site-url.ts
@@ -17,3 +17,26 @@ import type { BunPressConfig } from './types'
1717export function siteUrl(config: BunPressConfig): string {
1818 return (config.sitemap?.baseUrl || config.url || '').replace(/\/$/, '')
1919}
20
21/** Shared deployment prefix for rendered pages and generated navigation data. */
22export 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}
test/search-base-path.test.tsadded+39-0
Changes to test/search-base-path.test.ts
@@ -0,0 +1,39 @@
1import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
2import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { buildSearchIndex } from '../packages/bunpress/src/search-index'
6import type { BunPressConfig } from '../packages/bunpress/src/types'
7
8let root: string
9beforeAll(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})
16afterAll(async () => { if (root) await rm(root, { recursive: true, force: true }) })
17
18describe('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})