ReviewOS

also looking at this

stacks/bunpress

fix: resolve documentation source links in native rendering

#87
Merged chris wants to merge codex/docs-source-links into main
7 files +958 -254
docs/guide/markdown-features.mdmodified+48-0
Changes to docs/guide/markdown-features.md
@@ -294,6 +294,20 @@ Content here...
294294<!--@include: ./shared/footer.md-->
295295```
296296
297Put each include directive on its own line. Include paths are relative to the
298documentation root. Links inside included content resolve from that included
299file's directory, including recursive includes, while keeping the rendered
300page's locale. Inline includes are refused because their source boundaries
301cannot be preserved reliably through Markdown parsing.
302
303Use inline links in documents that expand includes. Reference definitions can
304bind uses across file boundaries, and the Markdown parser does not expose the
305definition's source location. These documents refuse reference definitions;
306literal examples in code spans, fenced blocks and HTML code remain supported.
307Reference validation is bounded to 256 candidate labels per expanded document.
308Split a document that exceeds this limit. Documents without includes continue
309to support reference-style links.
310
297311## Images
298312
299313### Basic Images
@@ -310,6 +324,40 @@ Content here...
310324
311325Images are automatically lazy-loaded for better performance.
312326
327## Documentation Links
328
329Use source-relative Markdown links so authored files also work on GitHub:
330
331```markdown
332[Overview](../overview.md#configuration)
333[This section](index.md)
334[Reference](README.md)
335[Named reference][guide]
336
337[guide]: ../guide.md?example=1#setup
338```
339
340Native builds and dev serving map these destinations to the generated page
341routes and apply the configured base path. For example, a page in `docs/guide/`
342linking to `../overview.md` becomes `/overview`, or `/docs/overview` with
343`basePath: '/docs'`. `index.md` uses its directory route; `README.md` keeps a
344`README` route. Query strings and fragments stay attached to the destination.
345
346Unqualified sources use the current rendered locale, including fallback
347content. An explicit language suffix such as `guide.en.md` selects that language
348even when the referring page uses another locale.
349
350External URLs, image destinations, links outside the documentation source tree,
351and literal code examples stay unchanged. Use explicit repository URLs for
352source and evidence references that should open on GitHub.
353
354A missing linked Markdown source or two sources sharing one output route
355refuses the build. A failed rebuild keeps the previous complete output. Builds
356in one process queue for the same output; another process holding that output's
357lock causes a clear refusal. Before manually removing a stale `.bunpress.lock`
358directory, inspect its `owner.json` and verify that the recorded builder has
359stopped.
360
313361## External Links
314362
315363External links automatically:
packages/bunpress/bin/cli.tsmodified+91-204
Changes to packages/bunpress/bin/cli.ts
@@ -13,6 +13,8 @@ import { generateRssFeed } from '../src/rss'
1313import { buildSearchIndex, SEARCH_INDEX_PATH } from '../src/search-index'
1414import { generateSitemap } from '../src/sitemap'
1515import { siteUrl } from '../src/site-url'
16import { createMarkdownPageCatalog, findMarkdownFiles } from '../src/markdown-pages'
17import { withBuildOutput } from '../src/build-output'
1618import { verifyBuildManifest, writeBuildManifest } from '../src/build-manifest'
1719import { cleanCommand } from './commands/clean'
1820import { configInitCommand, configShowCommand, configValidateCommand } from './commands/config'
@@ -25,6 +27,8 @@ import { statsCommand } from './commands/stats'
2527// Lazy-loaded to avoid failing when @stacksjs/ts-cloud is not available
2628// import { deployCommand } from './commands/deploy'
2729import { formatTime, logSuccess, Spinner } from './utils'
30
31export { findMarkdownFiles } from '../src/markdown-pages'
2832// import { markdown, stx } from '../src/plugin'
2933
3034const cli: CLI = new CLI('bunpress')
@@ -59,20 +63,6 @@ const defaultOptions = {
5963 verbose: config.verbose,
6064}
6165
62/**
63 * Find all markdown files in the given directory
64 */
65export async function findMarkdownFiles(dir: string): Promise<string[]> {
66 const mdGlob = new Glob('**/*.md')
67 const markdownFiles: string[] = []
68
69 for await (const file of mdGlob.scan(dir)) {
70 markdownFiles.push(join(dir, file))
71 }
72
73 return markdownFiles
74}
75
7666/**
7767 * Copy static assets from docs/public to the output directory
7868 */
@@ -104,83 +94,6 @@ async function copyStaticAssets(outdir: string, docsDir: string, verbose: boolea
10494 }
10595}
10696
107interface RenderTarget {
108 /** Markdown file to render. */
109 sourceFile: string
110 /** Path within the locale, without extension or locale prefix. */
111 relativePath: string
112 /** Locale this page is rendered for. */
113 locale: string
114}
115
116/**
117 * Expand the markdown files into one render target per (locale, page).
118 *
119 * Locale-specific sources are folded into the page they translate rather than
120 * being emitted as pages of their own: without this, `docs/es/guide.md` would
121 * also render at `/es/es/guide` under the default locale, and `guide.es.md`
122 * would render at the nonsense URL `/guide.es`.
123 */
124function buildRenderTargets(markdownFiles: string[], docsDir: string, i18n: any): RenderTarget[] {
125 const normalizedDocsDir = docsDir.replace(/^\.\//, '')
126 const toRelative = (file: string): string => file
127 .replace(/^\.\//, '')
128 .replace(normalizedDocsDir, '')
129 .replace(/^\//, '')
130 .replace(/\.md$/, '')
131
132 if (!i18n.enabled) {
133 return markdownFiles.map(file => ({ sourceFile: file, relativePath: toRelative(file), locale: i18n.defaultLocale }))
134 }
135
136 const locales: string[] = i18n.locales
137 const localeSet = new Set(locales)
138
139 // Index every source by the (locale, page) it provides.
140 const byLocale = new Map<string, Map<string, string>>()
141 for (const locale of locales) byLocale.set(locale, new Map())
142
143 const canonicalPages = new Set<string>()
144
145 for (const file of markdownFiles) {
146 const relative = toRelative(file)
147 const segments = relative.split('/')
148
149 // `<locale>/rest` — a directory per locale.
150 if (segments.length > 1 && localeSet.has(segments[0]) && segments[0] !== i18n.defaultLocale) {
151 const page = segments.slice(1).join('/')
152 byLocale.get(segments[0])!.set(page, file)
153 canonicalPages.add(page)
154 continue
155 }
156
157 // `page.<locale>` — a suffix per file.
158 const suffix = relative.match(/^(.*)\.([A-Za-z-]+)$/)
159 if (suffix && localeSet.has(suffix[2])) {
160 byLocale.get(suffix[2])!.set(suffix[1], file)
161 canonicalPages.add(suffix[1])
162 continue
163 }
164
165 byLocale.get(i18n.defaultLocale)!.set(relative, file)
166 canonicalPages.add(relative)
167 }
168
169 const targets: RenderTarget[] = []
170 for (const locale of locales) {
171 for (const page of canonicalPages) {
172 // An untranslated page renders from the default locale's source, so a
173 // reader browsing in Spanish never hits a 404 mid-site.
174 const sourceFile = byLocale.get(locale)!.get(page) ?? byLocale.get(i18n.defaultLocale)!.get(page)
175 if (!sourceFile)
176 continue
177 targets.push({ sourceFile, relativePath: page, locale })
178 }
179 }
180
181 return targets
182}
183
18497/**
18598 * Write the client search index next to the built pages.
18699 */
@@ -276,152 +189,126 @@ export async function buildDocs(options: CliOption = {}): Promise<boolean> {
276189
277190 const bunPressConfig = await resolveConfig(options)
278191 const baseOutdir = options.outdir || bunPressConfig.outDir || defaultOptions.outdir
279 // Build a complete replacement tree. A failed build leaves the last good
280 // output intact and a removed source page cannot survive as a stale file.
281192 const finalOutdir = join(baseOutdir, '.bunpress')
282 const outdir = `${finalOutdir}.tmp-${process.pid}`
283193 const docsDir = options.dir || bunPressConfig.docsDir || defaultOptions.docsdir
284194 const verbose = options.verbose ?? defaultOptions.verbose
285 const minify = options.minify ?? false
286 const sourcemap = options.sourcemap ?? false
287
288195 const startTime = performance.now()
289196 const spinner = new Spinner('Building documentation...')
290
291 if (!verbose) {
197 if (!verbose)
292198 spinner.start()
293 }
294
295 await rm(outdir, { recursive: true, force: true })
296 await mkdir(outdir, { recursive: true })
297
298 // Find all markdown files
299 const markdownFiles = await findMarkdownFiles(docsDir)
300
301 if (markdownFiles.length === 0) {
302 if (!verbose) {
303 spinner.fail('No markdown files found in docs directory')
304 }
305 else {
306 console.log('No markdown files found in docs directory')
307 }
308 return false
309 }
310
311 if (verbose) {
312 console.log(`Found ${markdownFiles.length} markdown files:`)
313 markdownFiles.forEach(file => console.log(`- ${file}`))
314 }
315199
316200 try {
317 // Use the same markdown-to-HTML transformation as the dev server
318 const { markdownToHtml, wrapInLayout } = await import('../src/serve')
319 const { loadI18nTranslations, resolveI18nConfig } = await import('../src/i18n')
320 const { mkdir: mkdirAsync, writeFile: writeFileAsync } = await import('node:fs/promises')
321
322 const i18n = await loadI18nTranslations(resolveI18nConfig(bunPressConfig), bunPressConfig)
323
324 if (verbose) {
325 console.log('Transforming markdown to HTML...')
326 }
327
328 // The set of pages to emit. Without i18n this is one entry per markdown
329 // file; with it, one per (locale, page) pair — including pages a locale has
330 // not translated, which fall back to the default locale's source so every
331 // locale is browsable end to end.
332 const renderTargets = buildRenderTargets(markdownFiles, docsDir, i18n)
333
334 for (const target of renderTargets) {
335 const markdown = await Bun.file(target.sourceFile).text()
336
337 // Convert markdown to HTML (handles frontmatter, hero, features, etc.)
338 const { html, frontmatter } = await markdownToHtml(markdown, docsDir)
339
340 const relativePath = target.relativePath
341 const currentPath = `/${relativePath}`
342
343 // Determine layout type
344 const layout = frontmatter.layout || 'doc'
345
346 // Wrap in layout (handles navbar, sidebar, SEO, etc.)
347 const fullHtml = await wrapInLayout(html, bunPressConfig, currentPath, layout, frontmatter, { i18n, locale: target.locale, sourceFile: target.sourceFile, docsDir })
348
349 // Determine output path. Emit directory-style (`<path>/index.html`) so the
350 // clean, extensionless URLs bunpress links to (`/guide/install`) resolve on
351 // directory-rewriting hosts and static servers alike. The homepage and any
352 // page already named `index` stay flat.
353 const isIndex = relativePath === 'index' || relativePath.endsWith('/index')
354 // Non-default locales live under their own prefix, matching the URLs the
355 // dev server serves and the switcher links to.
356 const outputBase = target.locale && target.locale !== i18n.defaultLocale && i18n.enabled
357 ? join(outdir, target.locale)
358 : outdir
359 const outputPath = isIndex
360 ? join(outputBase, `${relativePath}.html`)
361 : join(outputBase, relativePath, 'index.html')
362
363 // Ensure output directory exists
364 const outputDir = outputPath.substring(0, outputPath.lastIndexOf('/'))
365 if (outputDir) {
366 await mkdirAsync(outputDir, { recursive: true })
201 const count = await withBuildOutput(finalOutdir, async (outdir) => {
202 const markdownFiles = await findMarkdownFiles(docsDir)
203 if (markdownFiles.length === 0)
204 throw new Error('No markdown files found in docs directory')
205 if (verbose) {
206 console.log(`Found ${markdownFiles.length} markdown files:`)
207 markdownFiles.forEach(file => console.log(`- ${file}`))
367208 }
209 // Use the same markdown-to-HTML transformation as the dev server
210 const { markdownToHtml, wrapInLayout } = await import('../src/serve')
211 const { loadI18nTranslations, resolveI18nConfig } = await import('../src/i18n')
212 const { mkdir: mkdirAsync, writeFile: writeFileAsync } = await import('node:fs/promises')
368213
369 // Write HTML file
370 await writeFileAsync(outputPath, fullHtml)
214 const i18n = await loadI18nTranslations(resolveI18nConfig(bunPressConfig), bunPressConfig)
371215
372216 if (verbose) {
373 console.log(`Generated: ${outputPath}`)
217 console.log('Transforming markdown to HTML...')
374218 }
375 }
376219
377 if (verbose) {
378 console.log(`Processed ${markdownFiles.length} markdown files into ${renderTargets.length} pages.`)
379 }
220 // The set of pages to emit. Without i18n this is one entry per markdown
221 // file; with it, one per (locale, page) pair — including pages a locale has
222 // not translated, which fall back to the default locale's source so every
223 // locale is browsable end to end.
224 const catalog = createMarkdownPageCatalog(markdownFiles, docsDir, i18n)
225 const renderTargets = catalog.pages
226
227 for (const target of renderTargets) {
228 const markdown = await Bun.file(target.sourceFile).text()
229
230 // Convert markdown to HTML (handles frontmatter, hero, features, etc.)
231 const { html, frontmatter } = await markdownToHtml(markdown, docsDir, { page: target, catalog })
232
233 const relativePath = target.relativePath
234 const currentPath = `/${relativePath}`
235
236 // Determine layout type
237 const layout = frontmatter.layout || 'doc'
238
239 // Wrap in layout (handles navbar, sidebar, SEO, etc.)
240 const fullHtml = await wrapInLayout(html, bunPressConfig, currentPath, layout, frontmatter, { i18n, locale: target.locale, sourceFile: target.sourceFile, docsDir })
241
242 // Determine output path. Emit directory-style (`<path>/index.html`) so the
243 // clean, extensionless URLs bunpress links to (`/guide/install`) resolve on
244 // directory-rewriting hosts and static servers alike. The homepage and any
245 // page already named `index` stay flat.
246 const isIndex = relativePath === 'index' || relativePath.endsWith('/index')
247 // Non-default locales live under their own prefix, matching the URLs the
248 // dev server serves and the switcher links to.
249 const outputBase = target.locale && target.locale !== i18n.defaultLocale && i18n.enabled
250 ? join(outdir, target.locale)
251 : outdir
252 const outputPath = isIndex
253 ? join(outputBase, `${relativePath}.html`)
254 : join(outputBase, relativePath, 'index.html')
255
256 // Ensure output directory exists
257 const outputDir = outputPath.substring(0, outputPath.lastIndexOf('/'))
258 if (outputDir) {
259 await mkdirAsync(outputDir, { recursive: true })
260 }
380261
381 // Copy static assets from docs/public to output directory
382 await copyStaticAssets(outdir, docsDir, verbose)
262 // Write HTML file
263 await writeFileAsync(outputPath, fullHtml)
383264
384 // Copy docs/index.html to root as index.html (hero page)
385 await copyHeroToRoot(outdir)
265 if (verbose) {
266 console.log(`Generated: ${outputPath}`)
267 }
268 }
386269
387 // Generate 404 page
388 await generate404Page(outdir, bunPressConfig)
270 if (verbose) {
271 console.log(`Processed ${markdownFiles.length} markdown files into ${renderTargets.length} pages.`)
272 }
389273
390 // Search index consumed by the nav search dialog. The dev server builds
391 // this on demand at the same path; here it becomes a static file.
392 if (options.searchIndex !== false)
393 await generateSearchIndex(docsDir, outdir, bunPressConfig, verbose || false)
274 // Copy static assets from docs/public to output directory
275 await copyStaticAssets(outdir, docsDir, verbose)
394276
395 // Generate sitemap, robots.txt, and RSS feed
396 await generateSeoFiles(docsDir, outdir, bunPressConfig, verbose || false)
277 // Copy docs/index.html to root as index.html (hero page)
278 await copyHeroToRoot(outdir)
397279
398 if (options.checkManifest)
399 await verifyBuildManifest(outdir, options.checkManifest, version)
280 // Generate 404 page
281 await generate404Page(outdir, bunPressConfig)
400282
401 await rm(finalOutdir, { recursive: true, force: true })
402 await rename(outdir, finalOutdir)
283 // Search index consumed by the nav search dialog. The dev server builds
284 // this on demand at the same path; here it becomes a static file.
285 if (options.searchIndex !== false)
286 await generateSearchIndex(docsDir, outdir, bunPressConfig, verbose || false)
403287
404 if (options.manifest)
405 await writeBuildManifest(finalOutdir, options.manifest, version)
288 // Generate sitemap, robots.txt, and RSS feed
289 await generateSeoFiles(docsDir, outdir, bunPressConfig, verbose || false)
406290
407 const endTime = performance.now()
408 const duration = endTime - startTime
291 if (options.checkManifest)
292 await verifyBuildManifest(outdir, options.checkManifest, version)
293 return markdownFiles.length
294 }, async () => {
295 if (options.manifest)
296 await writeBuildManifest(finalOutdir, options.manifest, version)
297 })
409298
299 const duration = performance.now() - startTime
410300 if (!verbose) {
411 spinner.succeed(`Built ${markdownFiles.length} pages to HTML in ${formatTime(duration)}`)
301 spinner.succeed(`Built ${count} pages to HTML in ${formatTime(duration)}`)
412302 }
413303 else {
414304 logSuccess(`Build completed in ${formatTime(duration)}`)
415 console.log(`\nGenerated ${markdownFiles.length} HTML files in ${finalOutdir}`)
305 console.log(`\nGenerated ${count} HTML files in ${finalOutdir}`)
416306 }
417
418307 return true
419308 }
420309 catch (err) {
421 await rm(outdir, { recursive: true, force: true })
422 if (!verbose) {
310 if (!verbose)
423311 spinner.fail('Build failed')
424 }
425312 console.error('Error during build:', err)
426313 return false
427314 }
packages/bunpress/src/build-output.tsadded+92-0
Changes to packages/bunpress/src/build-output.ts
@@ -0,0 +1,92 @@
1import { randomUUID } from 'node:crypto'
2import { mkdir, mkdtemp, readFile, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises'
3import { basename, dirname, join, resolve } from 'node:path'
4
5const queues = new Map<string, Promise<void>>()
6
7async function serialized<T>(key: string, work: () => Promise<T>): Promise<T> {
8 const previous = queues.get(key) ?? Promise.resolve()
9 let release!: () => void
10 const current = new Promise<void>(done => { release = done })
11 const tail = previous.then(() => current)
12 queues.set(key, tail)
13 await previous
14 try { return await work() }
15 finally {
16 release()
17 if (queues.get(key) === tail)
18 queues.delete(key)
19 }
20}
21
22function missing(error: unknown): boolean {
23 return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'
24}
25
26/** A complete replacement, serialized locally and locked against other build processes. */
27export async function withBuildOutput<T>(directory: string, render: (staging: string) => Promise<T>, afterPublish?: () => Promise<void>): Promise<T> {
28 const requested = resolve(directory)
29 await mkdir(dirname(requested), { recursive: true })
30 const target = join(await realpath(dirname(requested)), basename(requested))
31 return serialized(target, async () => {
32 const lock = `${target}.lock`
33 try { await mkdir(lock, { mode: 0o700 }) }
34 catch (error) {
35 if (error !== null && typeof error === 'object' && 'code' in error && error.code === 'EEXIST')
36 throw new Error(`Documentation output is locked: ${lock}. Verify the recorded owner has stopped before removing a stale lock.`)
37 throw error
38 }
39 const owner = JSON.stringify({ pid: process.pid, token: randomUUID() })
40 const ownerPath = join(lock, 'owner.json')
41 let staging: string | undefined
42 let backup: string | undefined
43 let published = false
44 let committed = false
45 try {
46 await writeFile(ownerPath, owner, { flag: 'wx', mode: 0o600 })
47 staging = await mkdtemp(`${target}.tmp-`)
48 const result = await render(staging)
49 const previous = `${staging}.previous`
50 try { await rename(target, previous); backup = previous }
51 catch (error) { if (!missing(error)) throw error }
52 await rename(staging, target)
53 published = true
54 await afterPublish?.()
55 committed = true
56 if (backup)
57 await rm(backup, { recursive: true })
58 return result
59 }
60 catch (error) {
61 if (!committed && (published || backup)) {
62 try {
63 if (published)
64 await rm(target, { recursive: true })
65 if (backup)
66 await rename(backup, target)
67 }
68 catch (rollback) {
69 throw new AggregateError([error, rollback], `Documentation publication failed; previous output is retained at ${backup ?? target}`)
70 }
71 }
72 throw error
73 }
74 finally {
75 try {
76 if (staging)
77 await rm(staging, { recursive: true, force: true })
78 }
79 finally {
80 // Never delete a replacement owner's lock after external intervention.
81 const recorded = await readFile(ownerPath, 'utf8').catch(() => undefined)
82 if (recorded === owner) {
83 await rm(ownerPath)
84 await rmdir(lock)
85 }
86 else {
87 throw new Error(`Documentation output lock ownership changed: ${lock}`)
88 }
89 }
90 }
91 })
92}
packages/bunpress/src/html-entities.tsadded+23-0
Changes to packages/bunpress/src/html-entities.ts
@@ -0,0 +1,23 @@
1/**
2 * Text from HTML back to text: `Raster &amp; heatmap` to `Raster & heatmap`.
3 * A page title is read from its rendered `<h1>`, and is escaped again where
4 * it is written; left encoded, `&` came out as `&amp;amp;` in the tab.
5 */
6export function decodeHtmlEntities(value: string): string {
7 const named: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'', nbsp: '\u00A0' }
8 return value.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (whole, entity: string) => {
9 if (entity[0] === '#') {
10 const code = entity[1] === 'x' || entity[1] === 'X' ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10)
11 return Number.isFinite(code) && code > 0 && code <= 0x10FFFF ? String.fromCodePoint(code) : whole
12 }
13 return named[entity.toLowerCase()] ?? whole
14 })
15}
16
17export function escapeHtmlAttribute(value: string): string {
18 return String(value ?? '')
19 .replace(/&/g, '&amp;')
20 .replace(/</g, '&lt;')
21 .replace(/>/g, '&gt;')
22 .replace(/"/g, '&quot;')
23}
packages/bunpress/src/markdown-pages.tsadded+169-0
Changes to packages/bunpress/src/markdown-pages.ts
@@ -0,0 +1,169 @@
1import type { ResolvedI18n } from './i18n'
2import { Glob } from 'bun'
3import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
4import { localeContentCandidates, localizeUrl } from './i18n'
5import { decodeHtmlEntities, escapeHtmlAttribute } from './html-entities'
6
7export interface MarkdownPage {
8 readonly sourceFile: string
9 readonly relativePath: string
10 readonly locale: string
11}
12
13export interface MarkdownPageCatalog {
14 readonly pages: readonly MarkdownPage[]
15 findPage: (locale: string, path: string) => MarkdownPage | undefined
16 resolveLink: (page: MarkdownPage, destination: string) => string
17}
18
19export interface MarkdownPageContext {
20 readonly page: MarkdownPage
21 readonly catalog: MarkdownPageCatalog
22}
23
24/** Per-render boundaries for authored content expanded from another file. */
25export type MarkdownLinkOrigins = Map<string, MarkdownPage | null>
26
27export async function findMarkdownFiles(dir: string): Promise<string[]> {
28 const files: string[] = []
29 for await (const file of new Glob('**/*.md').scan({ cwd: dir, onlyFiles: true }))
30 files.push(join(dir, file))
31 return files
32}
33
34function encodePath(path: string): string {
35 return path.split('/').map(segment => encodeURIComponent(segment)).join('/')
36}
37
38function routeFor(path: string): string {
39 const route = path === 'index' ? '' : path.replace(/\/index$/, '/')
40 return `/${encodePath(route)}`
41}
42
43function routeKey(path: string): string {
44 const encoded = path.split('/').map(segment => encodeURIComponent(decodeURIComponent(segment))).join('/')
45 return encoded.replace(/\/+$/, '') || '/'
46}
47
48/** One finite source/route snapshot, owned by one build or one dev request. */
49export function createMarkdownPageCatalog(markdownFiles: readonly string[], docsDir: string, i18n: ResolvedI18n): MarkdownPageCatalog {
50 const root = resolve(docsDir)
51 const language = { ...i18n, locales: [...i18n.locales] }
52 const files = new Set(markdownFiles.map(file => resolve(file)))
53 const sources = new Map<string, { path: string, locale: string, explicitLocale: boolean }>()
54 const canonical = new Set<string>()
55 for (const file of files) {
56 let path = relative(root, file).split(sep).join('/').replace(/\.md$/, '')
57 if (path === '..' || path.startsWith('../') || isAbsolute(path))
58 throw new Error('Markdown source is outside the documentation root')
59 let locale = language.defaultLocale
60 let explicitLocale = false
61 if (language.enabled) {
62 const segments = path.split('/')
63 if (segments.length > 1 && language.locales.includes(segments[0]) && segments[0] !== language.defaultLocale) {
64 locale = segments.shift()!
65 explicitLocale = true
66 path = segments.join('/')
67 }
68 else {
69 const suffix = path.match(/^(.*)\.([A-Za-z-]+)$/)
70 if (suffix && language.locales.includes(suffix[2])) {
71 path = suffix[1]
72 locale = suffix[2]
73 explicitLocale = true
74 }
75 }
76 }
77 sources.set(file, { path, locale, explicitLocale })
78 canonical.add(path)
79 }
80
81 const pages: MarkdownPage[] = []
82 const byPage = new Map<string, MarkdownPage>()
83 const byRoute = new Map<string, MarkdownPage>()
84 const aliases = new Map<string, MarkdownPage>()
85 for (const locale of language.locales) {
86 for (const path of canonical) {
87 const sourceFile = language.enabled
88 ? localeContentCandidates(language, root, locale, `/${path}`).map(file => resolve(file)).find(file => files.has(file))
89 : resolve(root, `${path}.md`)
90 if (!sourceFile || !files.has(sourceFile))
91 continue
92 const page = Object.freeze({ sourceFile, relativePath: path, locale })
93 const key = `${locale}\0${routeKey(routeFor(path))}`
94 if (byRoute.has(key))
95 throw new Error(`Documentation pages share an output route: ${routeFor(path)}`)
96 byRoute.set(key, page)
97 byPage.set(`${locale}\0${path}`, page)
98 aliases.set(`${locale}\0${routeKey(`/${encodePath(path)}`)}`, page)
99 pages.push(page)
100 }
101 }
102
103 return Object.freeze({
104 pages: Object.freeze(pages),
105 findPage(locale: string, path: string): MarkdownPage | undefined {
106 const key = `${locale}\0${routeKey(path)}`
107 return byRoute.get(key) ?? aliases.get(key)
108 },
109 resolveLink(page: MarkdownPage, destination: string): string {
110 if (!destination || /^(?:[a-z][a-z\d+.-]*:|\/|#|\?)/i.test(destination))
111 return destination
112 const boundary = destination.search(/[?#]/)
113 const rawPath = boundary === -1 ? destination : destination.slice(0, boundary)
114 const suffix = boundary === -1 ? '' : destination.slice(boundary)
115 // Recognize the ASCII extension without requiring untouched asset URIs
116 // to contain UTF-8. Decode the whole pathname only for Markdown sources.
117 const candidate = rawPath.replace(/%([\da-f]{2})/gi, (_, byte) => String.fromCharCode(Number.parseInt(byte, 16)))
118 if (!/\.md$/i.test(candidate))
119 return destination
120 const path = decodeURIComponent(rawPath)
121 const target = resolve(dirname(page.sourceFile), path)
122 const within = relative(root, target)
123 if (within === '..' || within.startsWith(`..${sep}`) || isAbsolute(within))
124 return destination
125 const source = sources.get(target)
126 if (!source)
127 throw new Error(`Markdown link has no documentation source: ${rawPath}`)
128 const locale = source.explicitLocale ? source.locale : page.locale
129 const selected = byPage.get(`${locale}\0${source.path}`)
130 if (!selected)
131 throw new Error(`Markdown link has no rendered page: ${rawPath}`)
132 return `${localizeUrl(language, locale, routeFor(selected.relativePath))}${suffix}`
133 },
134 })
135}
136
137/** Parse actual anchor attributes; code text, images and external destinations stay intact. */
138export async function rewriteMarkdownSourceLinks(html: string, context: MarkdownPageContext, origins: MarkdownLinkOrigins = new Map()): Promise<string> {
139 const stack = [context.page]
140 const pending = new Set(origins.keys())
141 const rewritten = await new HTMLRewriter().onDocument({
142 comments(comment) {
143 const origin = origins.get(comment.text)
144 if (origin === undefined)
145 return
146 if (!pending.delete(comment.text))
147 throw new Error('Markdown include source boundary was duplicated')
148 if (origin === null) {
149 if (stack.length === 1)
150 throw new Error('Markdown include source boundaries are unbalanced')
151 stack.pop()
152 }
153 else {
154 stack.push(origin)
155 }
156 comment.remove()
157 },
158 }).on('a[href]', {
159 element(element) {
160 const href = decodeHtmlEntities(element.getAttribute('href')!)
161 const destination = context.catalog.resolveLink(stack[stack.length - 1], href)
162 if (destination !== href)
163 element.setAttribute('href', escapeHtmlAttribute(destination))
164 },
165 }).transform(new Response(html)).text()
166 if (pending.size || stack.length !== 1)
167 throw new Error('Markdown include source boundaries did not survive rendering')
168 return rewritten
169}
packages/bunpress/src/serve.tsmodified+81-50
Changes to packages/bunpress/src/serve.ts
@@ -1,5 +1,8 @@
1import type { MarkdownLinkOrigins, MarkdownPageContext } from './markdown-pages'
2import { createMarkdownPageCatalog, findMarkdownFiles, rewriteMarkdownSourceLinks } from './markdown-pages'
13import type { BunPressConfig, Frontmatter, NavItem, SidebarItem } from './types'
24import { Glob, YAML } from 'bun'
5import { randomUUID } from 'node:crypto'
36import { stat } from 'node:fs/promises'
47import { join, resolve } from 'node:path'
58import process from 'node:process'
@@ -13,7 +16,6 @@ import {
1316 generateHreflangTags,
1417 generateLocaleDetectionScript,
1518 loadI18nTranslations,
16 localeContentCandidates,
1719 localeLabel,
1820 localizeUrl,
1921 resolveI18nConfig,
@@ -28,6 +30,9 @@ import { messagePreviewCSS, renderMessagePreview } from './message-previews'
2830import { getThemeCSS } from './themes'
2931import { buildTocHierarchy, defaultTocConfig, extractHeadings, filterHeadings, generateInlineTocHtml, generateSlug, markdownHeadingText, reserveUniqueSlug } from './toc'
3032import { siteUrl } from './site-url'
33import { decodeHtmlEntities, escapeHtmlAttribute } from './html-entities'
34
35export { decodeHtmlEntities } from './html-entities'
3136
3237/**
3338 * Generate sidebar HTML from BunPress config.
@@ -236,30 +241,6 @@ function generateSocialLinks(config: BunPressConfig): string {
236241 .join('\n ')
237242}
238243
239/**
240 * Text from HTML back to text: `Raster &amp; heatmap` to `Raster & heatmap`.
241 * A page title is read from its rendered `<h1>`, and is escaped again where
242 * it is written; left encoded, `&` came out as `&amp;amp;` in the tab.
243 */
244export function decodeHtmlEntities(value: string): string {
245 const named: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'', nbsp: '\u00A0' }
246 return value.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (whole, entity: string) => {
247 if (entity[0] === '#') {
248 const code = entity[1] === 'x' || entity[1] === 'X' ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10)
249 return Number.isFinite(code) && code > 0 && code <= 0x10FFFF ? String.fromCodePoint(code) : whole
250 }
251 return named[entity.toLowerCase()] ?? whole
252 })
253}
254
255function escapeHtmlAttribute(value: string): string {
256 return String(value ?? '')
257 .replace(/&/g, '&amp;')
258 .replace(/</g, '&lt;')
259 .replace(/>/g, '&gt;')
260 .replace(/"/g, '&quot;')
261}
262
263244/**
264245 * Extract headings from HTML content and render the page outline in both of
265246 * its forms.
@@ -2552,7 +2533,7 @@ async function processCodeImports(content: string, rootDir: string): Promise<str
25522533 * Process markdown file inclusion
25532534 * Syntax: <!--@include: ./filepath.md--> or <!--@include: ./filepath.md{10-20}--> or <!--@include: ./filepath.md{#region}-->
25542535 */
2555async function processMarkdownIncludes(content: string, rootDir: string, processedFiles = new Set<string>()): Promise<string> {
2536async function processMarkdownIncludes(content: string, rootDir: string, processedFiles = new Set<string>(), pageContext?: MarkdownPageContext, origins?: MarkdownLinkOrigins): Promise<string> {
25562537 const includeRegex = /<!--@include:\s*([^\s{]+)(?:\{(\d+)-(\d+)\}|\{#([\w-]+)\})?\s*-->/g
25572538 const matches = Array.from(content.matchAll(includeRegex))
25582539
@@ -2561,6 +2542,12 @@ async function processMarkdownIncludes(content: string, rootDir: string, process
25612542 const [fullMatch, filepath, startLine, endLine, regionName] = match
25622543
25632544 try {
2545 if (pageContext) {
2546 const before = content.slice(0, match.index)
2547 const after = content.slice(match.index! + fullMatch.length)
2548 if (!/(?:^|\n) {0,3}$/.test(before) || !/^[ \t]*(?:\r?\n|$)/.test(after))
2549 throw new Error('Source-aware Markdown includes must be on their own line')
2550 }
25642551 // Resolve file path relative to root directory
25652552 const { join, resolve } = await import('node:path')
25662553 const fullPath = resolve(join(rootDir, filepath))
@@ -2613,11 +2600,22 @@ async function processMarkdownIncludes(content: string, rootDir: string, process
26132600 newProcessedFiles.add(fullPath)
26142601
26152602 // Recursively process includes in the included file
2616 includedContent = await processMarkdownIncludes(includedContent, rootDir, newProcessedFiles)
2603 includedContent = await withCodeMasked(includedContent, masked => processMarkdownIncludes(masked, rootDir, newProcessedFiles, pageContext, origins))
2604
2605 if (pageContext && origins) {
2606 // Parser-preserved comments delimit origins without parsing authored
2607 // Markdown links ourselves. Unique markers cannot alias author text.
2608 const token = `bunpress-source-${randomUUID()}`
2609 origins.set(`${token}:start`, { ...pageContext.page, sourceFile: fullPath })
2610 origins.set(`${token}:end`, null)
2611 includedContent = `\n<!--${token}:start-->\n\n${includedContent}\n\n<!--${token}:end-->\n`
2612 }
26172613
26182614 result = result.slice(0, match.index) + includedContent + result.slice(match.index! + fullMatch.length)
26192615 }
26202616 catch (error) {
2617 if (pageContext)
2618 throw error
26212619 console.error(`Error including markdown from ${filepath}:`, error)
26222620 }
26232621 }
@@ -3066,7 +3064,7 @@ function postProcessHeadings(html: string, customAnchors: Map<string, string[]>)
30663064 * Convert markdown to HTML using Bun's built-in markdown parser
30673065 * See: https://bun.com/docs/runtime/markdown
30683066 */
3069export async function markdownToHtml(markdown: string, rootDir: string = './docs'): Promise<{ html: string, frontmatter: any }> {
3067export async function markdownToHtml(markdown: string, rootDir: string = './docs', pageContext?: MarkdownPageContext): Promise<{ html: string, frontmatter: any }> {
30703068 // Parse frontmatter
30713069 const { frontmatter, content } = parseFrontmatter(markdown)
30723070
@@ -3077,7 +3075,7 @@ export async function markdownToHtml(markdown: string, rootDir: string = './docs
30773075 const heroHtml = await generateHero(frontmatter.hero)
30783076 const featuresHtml = await generateFeatures(frontmatter.features)
30793077 const bodyHtml = content.trim()
3080 ? await renderMarkdownBody(content, frontmatter, rootDir)
3078 ? await renderMarkdownBody(content, frontmatter, rootDir, pageContext)
30813079 : ''
30823080 const body = bodyHtml
30833081 ? `<div class="BPHome-content"><div class="container bp-doc vp-doc">${bodyHtml}</div></div>`
@@ -3088,7 +3086,7 @@ export async function markdownToHtml(markdown: string, rootDir: string = './docs
30883086 }
30893087 }
30903088
3091 const html = await renderMarkdownBody(content, frontmatter, rootDir)
3089 const html = await renderMarkdownBody(content, frontmatter, rootDir, pageContext)
30923090 return { html, frontmatter }
30933091}
30943092
@@ -3098,7 +3096,8 @@ export async function markdownToHtml(markdown: string, rootDir: string = './docs
30983096 * highlighting, and HTML post-processing. Shared by every layout (doc, page,
30993097 * and the body of a home page).
31003098 */
3101async function renderMarkdownBody(content: string, frontmatter: any, rootDir: string): Promise<string> {
3099async function renderMarkdownBody(content: string, frontmatter: any, rootDir: string, pageContext?: MarkdownPageContext): Promise<string> {
3100 const includeOrigins: MarkdownLinkOrigins = new Map()
31023101 // Resolve feature toggles from config (all default to true)
31033102 const features = (config as BunPressConfig).markdown?.features
31043103 const featureEnabled = (key: keyof import('./types').MarkdownFeaturesConfig): boolean => {
@@ -3129,7 +3128,7 @@ async function renderMarkdownBody(content: string, frontmatter: any, rootDir: st
31293128 // reason: they are all plain-text patterns, and a docs page is full of
31303129 // examples of them.
31313130 let processedContent = featureEnabled('includes')
3132 ? await withCodeMasked(content, masked => processMarkdownIncludes(masked, rootDir))
3131 ? await withCodeMasked(content, masked => processMarkdownIncludes(masked, rootDir, new Set(pageContext ? [pageContext.page.sourceFile] : []), pageContext, includeOrigins))
31333132 : content
31343133
31353134 // Build the stx render context: frontmatter + global data files (.data/*.json,
@@ -3242,13 +3241,47 @@ async function renderMarkdownBody(content: string, frontmatter: any, rootDir: st
32423241 // See: https://bun.com/docs/runtime/markdown
32433242 // markdown.parserOptions is merged last so a site can turn a GFM extension
32443243 // off, or enable one this default set does not cover.
3245 let finalHtml = Bun.markdown.html(processedContent, {
3244 const parserOptions = {
32463245 tables: true,
32473246 strikethrough: true,
32483247 tasklists: true,
32493248 autolinks: true,
32503249 ...((config as BunPressConfig).markdown?.parserOptions ?? {}),
3251 })
3250 }
3251 if (includeOrigins.size) {
3252 // Reference bindings span the entire expanded document. The parser does
3253 // not expose definition locations, so boundary markers cannot identify
3254 // their destination's source. Ask the native parser whether a candidate
3255 // label actually binds, using the same unmasked content as final parsing.
3256 // The probe precedes the content so an unclosed trailing HTML block
3257 // cannot hide it. Candidate discovery does not parse or rewrite links.
3258 const labels = new Set<string>()
3259 for (let end = processedContent.indexOf(']:'); end !== -1; end = processedContent.indexOf(']:', end + 2)) {
3260 for (let start = processedContent.lastIndexOf('[', end - 1); start !== -1; start = start === 0 ? -1 : processedContent.lastIndexOf('[', start - 1)) {
3261 const label = processedContent.slice(start + 1, end)
3262 if (labels.has(label))
3263 continue
3264 labels.add(label)
3265 if (labels.size > 256)
3266 throw new Error('Markdown include reference validation exceeded 256 candidate labels. Split the document or use inline links.')
3267 const probe = `bunpress-reference-${randomUUID()}`
3268 let bound = false
3269 Bun.markdown.render(`[${probe}][${label}]\n\n${processedContent}`, {
3270 link(children) {
3271 if (children === probe)
3272 bound = true
3273 return ''
3274 },
3275 }, parserOptions)
3276 if (bound)
3277 throw new Error('Markdown includes cannot be combined with reference definitions. Use inline links instead.')
3278 }
3279 }
3280 }
3281 let finalHtml = Bun.markdown.html(processedContent, parserOptions)
3282
3283 if (pageContext)
3284 finalHtml = await rewriteMarkdownSourceLinks(finalHtml, pageContext, includeOrigins)
32523285
32533286 // Restore code block placeholders with actual highlighted HTML
32543287 for (const [placeholder, codeHtml] of codeBlockMap) {
@@ -3367,24 +3400,22 @@ export async function startServer(options: {
33673400 // With i18n on, `/es/guide` resolves the Spanish copy first and the
33683401 // default-locale copy last, so an untranslated page still renders.
33693402 const { locale, path: localePath } = splitLocaleFromPath(i18n, path)
3370 const candidates = localeContentCandidates(i18n, root, locale, localePath)
3371 for (const mdPath of candidates) {
3372 try {
3373 const mdFile = Bun.file(mdPath)
3374 if (await mdFile.exists()) {
3375 const markdown = await mdFile.text()
3376 const { html, frontmatter } = await markdownToHtml(markdown, root)
3377 const layout = frontmatter.layout || 'doc'
3378 const wrappedHtml = await wrapInLayout(html, bunPressConfig, localePath, layout, frontmatter, { i18n, locale, sourceFile: mdPath, docsDir: root })
3379 return new Response(wrappedHtml, {
3380 headers: { 'Content-Type': 'text/html; charset=utf-8' },
3381 })
3382 }
3383 }
3384 catch {
3385 // Try the next candidate, then fall through to 404.
3403 try {
3404 const catalog = createMarkdownPageCatalog(await findMarkdownFiles(root), root, i18n)
3405 const page = catalog.findPage(locale, localePath)
3406 if (page) {
3407 const markdown = await Bun.file(page.sourceFile).text()
3408 const { html, frontmatter } = await markdownToHtml(markdown, root, { page, catalog })
3409 const layout = frontmatter.layout || 'doc'
3410 const wrappedHtml = await wrapInLayout(html, bunPressConfig, localePath, layout, frontmatter, { i18n, locale, sourceFile: page.sourceFile, docsDir: root })
3411 return new Response(wrappedHtml, {
3412 headers: { 'Content-Type': 'text/html; charset=utf-8' },
3413 })
33863414 }
33873415 }
3416 catch {
3417 return new Response('Documentation page could not be rendered', { status: 500 })
3418 }
33883419
33893420 // 404 response
33903421 return new Response(
test/markdown-source-links.test.tsadded+454-0
Changes to test/markdown-source-links.test.ts
@@ -0,0 +1,454 @@
1import type { BunPressConfig } from '../packages/bunpress/src/types'
2import { afterEach, expect, test } from 'bun:test'
3import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { dirname, join } from 'node:path'
6import { buildDocs } from '../packages/bunpress/bin/cli'
7import { decodeHtmlEntities, startServer } from '../packages/bunpress/src/serve'
8
9const temporary: string[] = []
10const config: BunPressConfig = {
11 verbose: false,
12 title: 'Source links fixture',
13 markdown: {},
14 basePath: '/docs',
15 i18n: { enabled: false, locales: ['en'] },
16 themeConfig: { nav: [], sidebar: {} },
17 sitemap: { enabled: false },
18 rss: { enabled: false },
19 robots: { enabled: false },
20 search: { enabled: false },
21}
22
23async function fixture(): Promise<{ root: string, docs: string, out: string, configFile: string }> {
24 const parent = process.env.BUNPRESS_SOURCE_LINK_FIXTURES || tmpdir()
25 await mkdir(parent, { recursive: true })
26 const root = await mkdtemp(join(parent, 'bunpress-source-links-'))
27 temporary.push(root)
28 const docs = join(root, 'docs')
29 const files: Record<string, string> = {
30 'README.md': '# Repository source only\n',
31 'docs/index.md': '# Home\n',
32 'docs/guides/index.md': '# Guides\n',
33 'docs/guides/README.md': '# Reference\n',
34 'docs/adr/decision-a.md': '# Decision\n',
35 'docs/adr/decision(two).md': '# Second decision\n',
36 'docs/adr/space name.md': '# Space\n',
37 'docs/images/sensor.svg': '<svg xmlns="http://www.w3.org/2000/svg"></svg>\n',
38 'docs/guides/start.md': [
39 '# Start',
40 '[decision](../adr/decision-a.md#decision)',
41 '[index](index.md#guides)',
42 '[readme](README.md#reference)',
43 '[home](../index.md)',
44 '[reference][decision-ref]',
45 '[decision-ref]: ../adr/decision-a.md#decision "Decision reference"',
46 '[escaped](../adr/decision\\(two\\).md)',
47 '[space](../adr/space%20name.md?case=1&safe=2#space)',
48 '[source](../../README.md)',
49 '[external](https://example.test/original.md?x=1#keep)',
50 '[image file](../images/sensor.svg)',
51 '![sensor](../images/sensor.svg)',
52 '`[literal](../adr/decision-a.md#decision)`',
53 '',
54 ].join('\n\n'),
55 }
56 for (const [path, contents] of Object.entries(files)) {
57 await mkdir(dirname(join(root, path)), { recursive: true })
58 await writeFile(join(root, path), contents)
59 }
60 const configFile = join(root, 'site.config.ts')
61 await writeFile(configFile, `export default ${JSON.stringify(config)}\n`)
62 return { root, docs, out: join(root, 'out'), configFile }
63}
64
65afterEach(async () => {
66 const roots = temporary.splice(0)
67 if (!process.env.BUNPRESS_SOURCE_LINK_FIXTURES)
68 await Promise.all(roots.map(root => rm(root, { recursive: true, force: true })))
69})
70
71async function assertLinks(html: string): Promise<void> {
72 const hrefs: string[] = []
73 const images: string[] = []
74 await new HTMLRewriter()
75 .on('a[href]', { element: element => { hrefs.push(decodeHtmlEntities(element.getAttribute('href')!)) } })
76 .on('img[src]', { element: element => { images.push(decodeHtmlEntities(element.getAttribute('src')!)) } })
77 .transform(new Response(html)).text()
78 expect(hrefs.filter(href => href === '/docs/adr/decision-a#decision')).toHaveLength(2)
79 for (const href of ['/docs/guides/#guides', '/docs/guides/README#reference', '/docs/', '/docs/adr/decision(two)', '/docs/adr/space%20name?case=1&safe=2#space'])
80 expect(hrefs).toContain(href)
81 expect(html).toContain('href="/docs/adr/space%20name?case=1&amp;safe=2#space"')
82 expect(hrefs).toContain('../../README.md')
83 expect(hrefs).toContain('https://example.test/original.md?x=1#keep')
84 expect(hrefs).toContain('../images/sensor.svg')
85 expect(images).toContain('../images/sensor.svg')
86 expect(html).toContain('[literal](../adr/decision-a.md#decision)')
87}
88
89test('native build resolves Markdown source links while authored source remains unchanged', async () => {
90 const f = await fixture()
91 const before = await readFile(join(f.docs, 'guides/start.md'), 'utf8')
92 expect(await buildDocs({ dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false })).toBe(true)
93 const html = await readFile(join(f.out, '.bunpress/guides/start/index.html'), 'utf8')
94 await assertLinks(html)
95 expect(await readFile(join(f.docs, 'guides/start.md'), 'utf8')).toBe(before)
96 for (const path of ['index.html', 'guides/index.html', 'guides/README/index.html', 'adr/decision-a/index.html', 'adr/decision(two)/index.html', 'adr/space name/index.html'])
97 expect(await Bun.file(join(f.out, '.bunpress', path)).exists()).toBe(true)
98}, 30000)
99
100test('native dev resolves the same source links and serves every generated destination', async () => {
101 const f = await fixture()
102 const { url, stop } = await startServer({ port: 0, root: f.docs, watch: false, quiet: true, config })
103 try {
104 const response = await fetch(`${url}/docs/guides/start`)
105 expect(response.status).toBe(200)
106 const html = await response.text()
107 await writeFile(join(f.root, 'dev-original.html'), html)
108 await assertLinks(html)
109 for (const path of ['/docs/adr/decision-a', '/docs/guides/', '/docs/guides/README', '/docs/', '/docs/adr/decision(two)', '/docs/adr/space%20name']) {
110 const target = await fetch(`${url}${path}`)
111 expect({ path, status: target.status }).toEqual({ path, status: 200 })
112 }
113 }
114 finally { stop() }
115}, 30000)
116
117test('a missing linked source refuses rebuild without replacing the previous complete output', async () => {
118 const f = await fixture()
119 expect(await buildDocs({ dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false })).toBe(true)
120 const path = join(f.out, '.bunpress/guides/start/index.html')
121 const previous = await readFile(path, 'utf8')
122 await rm(join(f.docs, 'adr/decision-a.md'))
123 expect(await buildDocs({ dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false })).toBe(false)
124 expect(await readFile(path, 'utf8')).toBe(previous)
125 const { url, stop } = await startServer({ port: 0, root: f.docs, watch: false, quiet: true, config })
126 try {
127 const response = await fetch(`${url}/docs/guides/start`)
128 expect(response.status).toBe(500)
129 expect(await response.text()).toBe('Documentation page could not be rendered')
130 }
131 finally { stop() }
132}, 30000)
133
134test('canonical output collisions refuse publication', async () => {
135 const f = await fixture()
136 await writeFile(join(f.docs, 'guides.md'), '# Ambiguous guides\n')
137 expect(await buildDocs({ dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false })).toBe(false)
138 expect(await Bun.file(join(f.out, '.bunpress/index.html')).exists()).toBe(false)
139}, 30000)
140
141test('concurrent native builds to one output publish whole source snapshots', async () => {
142 const a = await fixture()
143 const b = await fixture()
144 await writeFile(join(b.docs, 'adr/second-build.md'), '# Second build\n')
145 await writeFile(join(b.docs, 'guides/start.md'), '# Second build guide\n\n[second](../adr/second-build.md)\n')
146 for (const [f, marker] of [[a, 'SOURCE_SNAPSHOT_A'], [b, 'SOURCE_SNAPSHOT_B']] as const) {
147 for await (const file of new Bun.Glob('**/*.md').scan(f.docs)) {
148 const path = join(f.docs, file)
149 await writeFile(path, `${await readFile(path, 'utf8')}\n\n${marker}\n`)
150 }
151 }
152 const shared = join(a.root, 'shared-output')
153 expect(await Promise.all([
154 buildDocs({ dir: a.docs, outdir: shared, config: a.configFile, searchIndex: false }),
155 buildDocs({ dir: b.docs, outdir: shared, config: b.configFile, searchIndex: false }),
156 ])).toEqual([true, true])
157 const html = await readFile(join(shared, '.bunpress/guides/start/index.html'), 'utf8')
158 const selected = html.includes('SOURCE_SNAPSHOT_A') ? 'SOURCE_SNAPSHOT_A' : 'SOURCE_SNAPSHOT_B'
159 expect(html).toContain(selected)
160 expect(html).not.toContain(selected === 'SOURCE_SNAPSHOT_A' ? 'SOURCE_SNAPSHOT_B' : 'SOURCE_SNAPSHOT_A')
161 const outputRoot = join(shared, '.bunpress')
162 for await (const file of new Bun.Glob('**/*.html').scan(outputRoot)) {
163 if (file !== '404.html')
164 expect(await readFile(join(outputRoot, file), 'utf8')).toContain(selected)
165 }
166 expect(await Bun.file(join(outputRoot, 'adr/second-build/index.html')).exists()).toBe(selected === 'SOURCE_SNAPSHOT_B')
167}, 30000)
168
169test('locale fallback links use the current rendered locale and explicit translated sources stay selectable', async () => {
170 const f = await fixture()
171 await mkdir(join(f.docs, 'es/adr'), { recursive: true })
172 await writeFile(join(f.docs, 'es/adr/decision-a.md'), '# Decision\n\nLocalized source chosen.\n')
173 await writeFile(join(f.docs, 'guides/README.es.md'), '# Reference\n\nLocalized reference source.\n')
174 const site: BunPressConfig = { ...config, i18n: { enabled: true, locales: ['en', 'es'], defaultLocale: 'en' } }
175 await writeFile(f.configFile, `export default ${JSON.stringify(site)}\n`)
176 expect(await buildDocs({ dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false })).toBe(true)
177 const built = await readFile(join(f.out, '.bunpress/es/guides/start/index.html'), 'utf8')
178 expect(built).toContain('href="/docs/es/adr/decision-a#decision"')
179 expect(built).toContain('href="/docs/es/guides/README#reference"')
180 expect(built).toContain('href="/docs/es"')
181 expect(await readFile(join(f.out, '.bunpress/es/adr/decision-a/index.html'), 'utf8')).toContain('Localized source chosen.')
182 const { url, stop } = await startServer({ port: 0, root: f.docs, watch: false, quiet: true, config: site })
183 try {
184 const response = await fetch(`${url}/docs/es/guides/start`)
185 expect(response.status).toBe(200)
186 const html = await response.text()
187 expect(html).toContain('href="/docs/es/adr/decision-a#decision"')
188 expect(html).toContain('href="/docs/es/guides/README#reference"')
189 const target = await fetch(`${url}/docs/es/adr/decision-a`)
190 expect(target.status).toBe(200)
191 expect(await target.text()).toContain('Localized source chosen.')
192 // A live request gets a fresh source catalog, rather than a stale build cache.
193 await rm(join(f.docs, 'es/adr/decision-a.md'))
194 const fallback = await fetch(`${url}/docs/es/adr/decision-a`)
195 expect(fallback.status).toBe(200)
196 expect(await fallback.text()).not.toContain('Localized source chosen.')
197 }
198 finally { stop() }
199}, 30000)
200
201test('selected-source read failure refuses rebuild and dev render without serving a fallback', async () => {
202 const f = await fixture()
203 const { chmod } = await import('node:fs/promises')
204 expect(await buildDocs({ dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false })).toBe(true)
205 const output = join(f.out, '.bunpress/guides/start/index.html')
206 const before = await readFile(output, 'utf8')
207 const source = join(f.docs, 'guides/start.md')
208 await chmod(source, 0o000)
209 const { url, stop } = await startServer({ port: 0, root: f.docs, watch: false, quiet: true, config })
210 try {
211 expect(await buildDocs({ dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false })).toBe(false)
212 expect(await readFile(output, 'utf8')).toBe(before)
213 expect((await fetch(`${url}/docs/guides/start`)).status).toBe(500)
214 }
215 finally {
216 stop()
217 await chmod(source, 0o600)
218 }
219}, 30000)
220
221test('a retained external build lock refuses publication and a later build can replace removed pages', async () => {
222 const f = await fixture()
223 const { basename } = await import('node:path')
224 const args = { dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false }
225 expect(await buildDocs(args)).toBe(true)
226 const page = join(f.out, '.bunpress/guides/start/index.html')
227 const before = await readFile(page, 'utf8')
228 const lock = join(f.out, '.bunpress.lock')
229 await mkdir(lock)
230 await writeFile(join(lock, 'owner.json'), '{"pid":1,"token":"another-builder"}')
231 expect(await buildDocs(args)).toBe(false)
232 expect(await readFile(page, 'utf8')).toBe(before)
233 expect(await readFile(join(lock, 'owner.json'), 'utf8')).toBe('{"pid":1,"token":"another-builder"}')
234 await rm(lock, { recursive: true })
235 await rm(join(f.docs, 'adr/decision(two).md'))
236 await writeFile(join(f.docs, 'guides/start.md'), '# Updated guide\n\n[decision](../adr/decision-a.md#decision)\n')
237 expect(await buildDocs(args)).toBe(true)
238 expect(await Bun.file(join(f.out, '.bunpress/adr/decision(two)/index.html')).exists()).toBe(false)
239 expect(await readFile(page, 'utf8')).toContain('Updated guide')
240 const remnants: string[] = []
241 for await (const entry of new Bun.Glob('.bunpress.*').scan({ cwd: f.out, onlyFiles: false }))
242 remnants.push(basename(entry))
243 expect(remnants).toEqual([])
244}, 30000)
245
246test('post-publication metadata failure restores the previous complete output and releases the lock', async () => {
247 const f = await fixture()
248 const args = { dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false }
249 expect(await buildDocs(args)).toBe(true)
250 const page = join(f.out, '.bunpress/guides/start/index.html')
251 const before = await readFile(page, 'utf8')
252 await writeFile(join(f.docs, 'guides/start.md'), '# Replacement must roll back\n')
253 const manifestDirectory = join(f.root, 'manifest-directory')
254 await mkdir(manifestDirectory)
255 expect(await buildDocs({ ...args, manifest: manifestDirectory })).toBe(false)
256 expect(await readFile(page, 'utf8')).toBe(before)
257 expect(await buildDocs(args)).toBe(true)
258 expect(await readFile(page, 'utf8')).toContain('Replacement must roll back')
259}, 30000)
260
261test('opaque percent-encoded asset URLs stay intact in native build and dev', async () => {
262 const f = await fixture()
263 await writeFile(join(f.docs, 'guides/start.md'), '# Assets\n\n[opaque asset](../assets/%FF.pdf?download=1#original)\n\n[encoded guide](../adr/decision-a%2Emd#decision)\n')
264 expect(await buildDocs({ dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false })).toBe(true)
265 const built = await readFile(join(f.out, '.bunpress/guides/start/index.html'), 'utf8')
266 expect(built).toContain('href="../assets/%FF.pdf?download=1#original"')
267 expect(built).toContain('href="/docs/adr/decision-a#decision"')
268 const { url, stop } = await startServer({ port: 0, root: f.docs, watch: false, quiet: true, config })
269 try {
270 const response = await fetch(`${url}/docs/guides/start`)
271 expect(response.status).toBe(200)
272 const html = await response.text()
273 expect(html).toContain('href="../assets/%FF.pdf?download=1#original"')
274 expect(html).toContain('href="/docs/adr/decision-a#decision"')
275 }
276 finally { stop() }
277}, 30000)
278
279test('an explicit default-language source keeps that language from a translated page', async () => {
280 const f = await fixture()
281 await mkdir(join(f.docs, 'es/adr'), { recursive: true })
282 await writeFile(join(f.docs, 'explicit.en.md'), '# English\n\nEXPLICIT_ENGLISH_CONTENT\n')
283 await writeFile(join(f.docs, 'es/explicit.md'), '# Spanish\n\nTRANSLATED_CONTENT\n')
284 await writeFile(join(f.docs, 'es/adr/decision-a.md'), '# Decision\n\nSPANISH_DECISION\n')
285 await writeFile(join(f.docs, 'es/start.md'), '# Spanish start\n\n[English](../explicit.en.md)\n\n[current locale](../adr/decision-a.md)\n')
286 const site: BunPressConfig = { ...config, i18n: { enabled: true, locales: ['en', 'es'], defaultLocale: 'en' } }
287 await writeFile(f.configFile, `export default ${JSON.stringify(site)}\n`)
288 expect(await buildDocs({ dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false })).toBe(true)
289 const built = await readFile(join(f.out, '.bunpress/es/start/index.html'), 'utf8')
290 expect(built).toContain('href="/docs/explicit"')
291 expect(built).toContain('href="/docs/es/adr/decision-a"')
292 expect(await readFile(join(f.out, '.bunpress/explicit/index.html'), 'utf8')).toContain('EXPLICIT_ENGLISH_CONTENT')
293 const { url, stop } = await startServer({ port: 0, root: f.docs, watch: false, quiet: true, config: site })
294 try {
295 const response = await fetch(`${url}/docs/es/start`)
296 expect(response.status).toBe(200)
297 const html = await response.text()
298 expect(html).toContain('href="/docs/explicit"')
299 expect(html).toContain('href="/docs/es/adr/decision-a"')
300 expect(await (await fetch(`${url}/docs/explicit`)).text()).toContain('EXPLICIT_ENGLISH_CONTENT')
301 expect(await (await fetch(`${url}/docs/es/adr/decision-a`)).text()).toContain('SPANISH_DECISION')
302 }
303 finally { stop() }
304}, 30000)
305
306test('recursive included links keep their source directories and the rendered fallback locale', async () => {
307 const f = await fixture()
308 await mkdir(join(f.docs, 'parts/deep'), { recursive: true })
309 await mkdir(join(f.docs, 'es/parts'), { recursive: true })
310 await writeFile(join(f.docs, 'guides/target.md'), '# Host target\n')
311 await writeFile(join(f.docs, 'parts/target.md'), '# Included target\n')
312 await writeFile(join(f.docs, 'es/parts/target.md'), '# Included target\n\nSPANISH_INCLUDED_TARGET\n')
313 await writeFile(join(f.docs, 'parts/deep/target.md'), '# Deep target\n')
314 await writeFile(join(f.docs, 'parts/deep/snippet.md'), '[nested](target.md#deep-target)\n\n`[literal]: target.md`\n')
315 await writeFile(join(f.docs, 'parts/snippet.md'), '[included](target.md#included-target)\n\n<!--@include: ./parts/deep/snippet.md-->\n\n`[literal](target.md)`\n')
316 await writeFile(join(f.docs, 'guides/start.md'), '# Included source\n\n[outer](target.md#host-target)\n\n<!--@include: ./parts/snippet.md-->\n\n[outer after](target.md#host-target)\n')
317 const site: BunPressConfig = { ...config, i18n: { enabled: true, locales: ['en', 'es'], defaultLocale: 'en' } }
318 await writeFile(f.configFile, `export default ${JSON.stringify(site)}\n`)
319 expect(await buildDocs({ dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false })).toBe(true)
320 const built = await readFile(join(f.out, '.bunpress/es/guides/start/index.html'), 'utf8')
321 expect(built.includes('href="/docs/es/parts/target#included-target"')).toBe(true)
322 expect(built.includes('href="/docs/es/parts/deep/target#deep-target"')).toBe(true)
323 expect(built.match(/href="\/docs\/es\/guides\/target#host-target"/g)).toHaveLength(2)
324 expect(built).toContain('[literal](target.md)')
325 expect(built).toContain('[literal]: target.md')
326 expect(built).not.toContain('<!--bunpress-source-')
327 const { url, stop } = await startServer({ port: 0, root: f.docs, watch: false, quiet: true, config: site })
328 try {
329 const response = await fetch(`${url}/docs/es/guides/start`)
330 expect(response.status).toBe(200)
331 const html = await response.text()
332 await writeFile(join(f.root, 'included-dev-original.html'), html)
333 expect(html.includes('href="/docs/es/parts/target#included-target"')).toBe(true)
334 expect(html.includes('href="/docs/es/parts/deep/target#deep-target"')).toBe(true)
335 expect(html.match(/href="\/docs\/es\/guides\/target#host-target"/g)).toHaveLength(2)
336 expect(await (await fetch(`${url}/docs/es/parts/target`)).text()).toContain('SPANISH_INCLUDED_TARGET')
337 expect((await fetch(`${url}/docs/es/parts/deep/target`)).status).toBe(200)
338 }
339 finally { stop() }
340}, 30000)
341
342test('unreadable included content and unsupported inline includes refuse dev rendering and preserve built output', async () => {
343 const f = await fixture()
344 const { chmod } = await import('node:fs/promises')
345 await mkdir(join(f.docs, 'parts'), { recursive: true })
346 const snippet = join(f.docs, 'parts/snippet.md')
347 const host = join(f.docs, 'guides/start.md')
348 await writeFile(snippet, 'Included content\n')
349 await writeFile(host, '# Include host\n\n<!--@include: ./parts/snippet.md-->\n')
350 const args = { dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false }
351 expect(await buildDocs(args)).toBe(true)
352 const output = join(f.out, '.bunpress/guides/start/index.html')
353 const before = await readFile(output, 'utf8')
354 const { url, stop } = await startServer({ port: 0, root: f.docs, watch: false, quiet: true, config })
355 try {
356 await chmod(snippet, 0o000)
357 expect((await fetch(`${url}/docs/guides/start`)).status).toBe(500)
358 expect(await buildDocs(args)).toBe(false)
359 expect(await readFile(output, 'utf8')).toBe(before)
360 await chmod(snippet, 0o600)
361 await writeFile(host, '# Inline include\n\nBefore <!--@include: ./parts/snippet.md--> after\n')
362 expect((await fetch(`${url}/docs/guides/start`)).status).toBe(500)
363 expect(await buildDocs(args)).toBe(false)
364 expect(await readFile(output, 'utf8')).toBe(before)
365 }
366 finally {
367 stop()
368 await chmod(snippet, 0o600)
369 }
370}, 30000)
371
372test('reference definitions crossing include boundaries are refused instead of choosing the host target', async () => {
373 const f = await fixture()
374 await mkdir(join(f.docs, 'parts'), { recursive: true })
375 await writeFile(join(f.docs, 'parts/target.md'), '# Included reference target\n')
376 await writeFile(join(f.docs, 'guides/target.md'), '# Wrong host target\n')
377 await writeFile(join(f.docs, 'parts/definitions.md'), '[shared]: target.md\n')
378 const args = { dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false }
379 expect(await buildDocs(args)).toBe(true)
380 const output = join(f.out, '.bunpress/guides/start/index.html')
381 const previous = await readFile(output, 'utf8')
382 const { url, stop } = await startServer({ port: 0, root: f.docs, watch: false, quiet: true, config })
383 try {
384 for (const [host, included] of [
385 ['[shared link][shared]\n\n<!--@include: ./parts/definitions.md-->\n', '[shared]: target.md\n'],
386 ['[shared]: target.md\n\n<!--@include: ./parts/definitions.md-->\n', '[shared link][shared]\n'],
387 ['[shared link][shared<code>]\n\n<!--@include: ./parts/definitions.md-->\n', '[shared<code>]: target.md\n'],
388 ['[shared link][shared\\]label]\n\n<!--@include: ./parts/definitions.md-->\n', '[shared\\]label]: target.md\n'],
389 ['[shared link][shared\\[label]\n\n<!--@include: ./parts/definitions.md-->\n', '[shared\\[label]: target.md\n'],
390 ['[shared link][shared\nlabel]\n\n<!--@include: ./parts/definitions.md-->\n', '[shared\nlabel]: target.md\n'],
391 ['[shared link][a`b]\n\n<!--@include: ./parts/definitions.md-->\n', '[a`b]: target.md "title`"\n'],
392 ]) {
393 await writeFile(join(f.docs, 'guides/start.md'), `# Shared references\n\n${host}`)
394 await writeFile(join(f.docs, 'parts/definitions.md'), included)
395 expect(await buildDocs(args)).toBe(false)
396 expect(await readFile(output, 'utf8')).toBe(previous)
397 const response = await fetch(`${url}/docs/guides/start`)
398 expect(response.status).toBe(500)
399 expect(await response.text()).toBe('Documentation page could not be rendered')
400 }
401 }
402 finally { stop() }
403}, 30000)
404
405test('literal reference definitions in rendered code groups stay code when a document includes content', async () => {
406 const f = await fixture()
407 await mkdir(join(f.docs, 'parts'), { recursive: true })
408 await writeFile(join(f.docs, 'parts/snippet.md'), 'Included prose\n')
409 await writeFile(join(f.docs, 'guides/start.md'), [
410 '[home](../index.md)\n\n# Literal references',
411 '<!--@include: ./parts/snippet.md-->',
412 '::: code-group\n```unknownreferenceexample [Reference syntax]\n[literal]: target.md\n```\n:::',
413 '<pre><code>[html literal]: target.md</code></pre>',
414 '<span title="[attribute]: target.md">Attribute example</span>',
415 ].join('\n\n'))
416 expect(await buildDocs({ dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false })).toBe(true)
417 const built = await readFile(join(f.out, '.bunpress/guides/start/index.html'), 'utf8')
418 expect(built).toContain('[literal]: target.md')
419 expect(built).toContain('class="code-group"')
420 expect(built).toContain('[html literal]: target.md')
421 expect(built).toContain('title="[attribute]: target.md"')
422 expect(built).not.toContain('<!--bunpress-source-')
423 expect(built).toContain('Included prose')
424 const { url, stop } = await startServer({ port: 0, root: f.docs, watch: false, quiet: true, config })
425 try {
426 const response = await fetch(`${url}/docs/guides/start`)
427 expect(response.status).toBe(200)
428 const html = await response.text()
429 expect(html).toContain('[literal]: target.md')
430 expect(html).toContain('class="code-group"')
431 expect(html).toContain('[html literal]: target.md')
432 expect(html).toContain('title="[attribute]: target.md"')
433 expect(html).not.toContain('<!--bunpress-source-')
434 expect(html).toContain('Included prose')
435 }
436 finally { stop() }
437}, 30000)
438
439test('include reference validation refuses excessive candidates without publishing partial output', async () => {
440 const f = await fixture()
441 await mkdir(join(f.docs, 'parts'), { recursive: true })
442 await writeFile(join(f.docs, 'parts/snippet.md'), 'Included prose\n')
443 const args = { dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false }
444 expect(await buildDocs(args)).toBe(true)
445 const output = join(f.out, '.bunpress/guides/start/index.html')
446 const previous = await readFile(output, 'utf8')
447 const examples = Array.from({ length: 40 }, (_, index) => `<pre><code>[example-${index}]: target.md</code></pre>`).join('\n\n')
448 await writeFile(join(f.docs, 'guides/start.md'), `# Many reference examples\n\n<!--@include: ./parts/snippet.md-->\n\n${examples}\n`)
449 expect(await buildDocs(args)).toBe(false)
450 expect(await readFile(output, 'utf8')).toBe(previous)
451 const { url, stop } = await startServer({ port: 0, root: f.docs, watch: false, quiet: true, config })
452 try { expect((await fetch(`${url}/docs/guides/start`)).status).toBe(500) }
453 finally { stop() }
454}, 30000)