also looking at this
fix: resolve documentation source links in native rendering
#87
7 files
+958
-254
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -294,6 +294,20 @@ Content here... | ||
| 294 | 294 | <!--@include: ./shared/footer.md--> |
| 295 | 295 | ``` |
| 296 | 296 | |
| 297 | Put each include directive on its own line. Include paths are relative to the | |
| 298 | documentation root. Links inside included content resolve from that included | |
| 299 | file's directory, including recursive includes, while keeping the rendered | |
| 300 | page's locale. Inline includes are refused because their source boundaries | |
| 301 | cannot be preserved reliably through Markdown parsing. | |
| 302 | ||
| 303 | Use inline links in documents that expand includes. Reference definitions can | |
| 304 | bind uses across file boundaries, and the Markdown parser does not expose the | |
| 305 | definition's source location. These documents refuse reference definitions; | |
| 306 | literal examples in code spans, fenced blocks and HTML code remain supported. | |
| 307 | Reference validation is bounded to 256 candidate labels per expanded document. | |
| 308 | Split a document that exceeds this limit. Documents without includes continue | |
| 309 | to support reference-style links. | |
| 310 | ||
| 297 | 311 | ## Images |
| 298 | 312 | |
| 299 | 313 | ### Basic Images |
| @@ -310,6 +324,40 @@ Content here... | ||
| 310 | 324 | |
| 311 | 325 | Images are automatically lazy-loaded for better performance. |
| 312 | 326 | |
| 327 | ## Documentation Links | |
| 328 | ||
| 329 | Use 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 | ||
| 340 | Native builds and dev serving map these destinations to the generated page | |
| 341 | routes and apply the configured base path. For example, a page in `docs/guide/` | |
| 342 | linking 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 | ||
| 346 | Unqualified sources use the current rendered locale, including fallback | |
| 347 | content. An explicit language suffix such as `guide.en.md` selects that language | |
| 348 | even when the referring page uses another locale. | |
| 349 | ||
| 350 | External URLs, image destinations, links outside the documentation source tree, | |
| 351 | and literal code examples stay unchanged. Use explicit repository URLs for | |
| 352 | source and evidence references that should open on GitHub. | |
| 353 | ||
| 354 | A missing linked Markdown source or two sources sharing one output route | |
| 355 | refuses the build. A failed rebuild keeps the previous complete output. Builds | |
| 356 | in one process queue for the same output; another process holding that output's | |
| 357 | lock causes a clear refusal. Before manually removing a stale `.bunpress.lock` | |
| 358 | directory, inspect its `owner.json` and verify that the recorded builder has | |
| 359 | stopped. | |
| 360 | ||
| 313 | 361 | ## External Links |
| 314 | 362 | |
| 315 | 363 | External links automatically: |
| @@ -13,6 +13,8 @@ import { generateRssFeed } from '../src/rss' | ||
| 13 | 13 | import { buildSearchIndex, SEARCH_INDEX_PATH } from '../src/search-index' |
| 14 | 14 | import { generateSitemap } from '../src/sitemap' |
| 15 | 15 | import { siteUrl } from '../src/site-url' |
| 16 | import { createMarkdownPageCatalog, findMarkdownFiles } from '../src/markdown-pages' | |
| 17 | import { withBuildOutput } from '../src/build-output' | |
| 16 | 18 | import { verifyBuildManifest, writeBuildManifest } from '../src/build-manifest' |
| 17 | 19 | import { cleanCommand } from './commands/clean' |
| 18 | 20 | import { configInitCommand, configShowCommand, configValidateCommand } from './commands/config' |
| @@ -25,6 +27,8 @@ import { statsCommand } from './commands/stats' | ||
| 25 | 27 | // Lazy-loaded to avoid failing when @stacksjs/ts-cloud is not available |
| 26 | 28 | // import { deployCommand } from './commands/deploy' |
| 27 | 29 | import { formatTime, logSuccess, Spinner } from './utils' |
| 30 | ||
| 31 | export { findMarkdownFiles } from '../src/markdown-pages' | |
| 28 | 32 | // import { markdown, stx } from '../src/plugin' |
| 29 | 33 | |
| 30 | 34 | const cli: CLI = new CLI('bunpress') |
| @@ -59,20 +63,6 @@ const defaultOptions = { | ||
| 59 | 63 | verbose: config.verbose, |
| 60 | 64 | } |
| 61 | 65 | |
| 62 | /** | |
| 63 | * Find all markdown files in the given directory | |
| 64 | */ | |
| 65 | export 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 | ||
| 76 | 66 | /** |
| 77 | 67 | * Copy static assets from docs/public to the output directory |
| 78 | 68 | */ |
| @@ -104,83 +94,6 @@ async function copyStaticAssets(outdir: string, docsDir: string, verbose: boolea | ||
| 104 | 94 | } |
| 105 | 95 | } |
| 106 | 96 | |
| 107 | interface 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 | */ | |
| 124 | function 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 | ||
| 184 | 97 | /** |
| 185 | 98 | * Write the client search index next to the built pages. |
| 186 | 99 | */ |
| @@ -276,152 +189,126 @@ export async function buildDocs(options: CliOption = {}): Promise<boolean> { | ||
| 276 | 189 | |
| 277 | 190 | const bunPressConfig = await resolveConfig(options) |
| 278 | 191 | 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. | |
| 281 | 192 | const finalOutdir = join(baseOutdir, '.bunpress') |
| 282 | const outdir = `${finalOutdir}.tmp-${process.pid}` | |
| 283 | 193 | const docsDir = options.dir || bunPressConfig.docsDir || defaultOptions.docsdir |
| 284 | 194 | const verbose = options.verbose ?? defaultOptions.verbose |
| 285 | const minify = options.minify ?? false | |
| 286 | const sourcemap = options.sourcemap ?? false | |
| 287 | ||
| 288 | 195 | const startTime = performance.now() |
| 289 | 196 | const spinner = new Spinner('Building documentation...') |
| 290 | ||
| 291 | if (!verbose) { | |
| 197 | if (!verbose) | |
| 292 | 198 | 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 | } | |
| 315 | 199 | |
| 316 | 200 | 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}`)) | |
| 367 | 208 | } |
| 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') | |
| 368 | 213 | |
| 369 | // Write HTML file | |
| 370 | await writeFileAsync(outputPath, fullHtml) | |
| 214 | const i18n = await loadI18nTranslations(resolveI18nConfig(bunPressConfig), bunPressConfig) | |
| 371 | 215 | |
| 372 | 216 | if (verbose) { |
| 373 | console.log(`Generated: ${outputPath}`) | |
| 217 | console.log('Transforming markdown to HTML...') | |
| 374 | 218 | } |
| 375 | } | |
| 376 | 219 | |
| 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 | } | |
| 380 | 261 | |
| 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) | |
| 383 | 264 | |
| 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 | } | |
| 386 | 269 | |
| 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 | } | |
| 389 | 273 | |
| 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) | |
| 394 | 276 | |
| 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) | |
| 397 | 279 | |
| 398 | if (options.checkManifest) | |
| 399 | await verifyBuildManifest(outdir, options.checkManifest, version) | |
| 280 | // Generate 404 page | |
| 281 | await generate404Page(outdir, bunPressConfig) | |
| 400 | 282 | |
| 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) | |
| 403 | 287 | |
| 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) | |
| 406 | 290 | |
| 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 | }) | |
| 409 | 298 | |
| 299 | const duration = performance.now() - startTime | |
| 410 | 300 | 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)}`) | |
| 412 | 302 | } |
| 413 | 303 | else { |
| 414 | 304 | 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}`) | |
| 416 | 306 | } |
| 417 | ||
| 418 | 307 | return true |
| 419 | 308 | } |
| 420 | 309 | catch (err) { |
| 421 | await rm(outdir, { recursive: true, force: true }) | |
| 422 | if (!verbose) { | |
| 310 | if (!verbose) | |
| 423 | 311 | spinner.fail('Build failed') |
| 424 | } | |
| 425 | 312 | console.error('Error during build:', err) |
| 426 | 313 | return false |
| 427 | 314 | } |
| @@ -0,0 +1,92 @@ | ||
| 1 | import { randomUUID } from 'node:crypto' | |
| 2 | import { mkdir, mkdtemp, readFile, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises' | |
| 3 | import { basename, dirname, join, resolve } from 'node:path' | |
| 4 | ||
| 5 | const queues = new Map<string, Promise<void>>() | |
| 6 | ||
| 7 | async 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 | ||
| 22 | function 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. */ | |
| 27 | export 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 | } | |
| @@ -0,0 +1,23 @@ | ||
| 1 | /** | |
| 2 | * Text from HTML back to text: `Raster & 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;` in the tab. | |
| 5 | */ | |
| 6 | export 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 | ||
| 17 | export function escapeHtmlAttribute(value: string): string { | |
| 18 | return String(value ?? '') | |
| 19 | .replace(/&/g, '&') | |
| 20 | .replace(/</g, '<') | |
| 21 | .replace(/>/g, '>') | |
| 22 | .replace(/"/g, '"') | |
| 23 | } | |
| @@ -0,0 +1,169 @@ | ||
| 1 | import type { ResolvedI18n } from './i18n' | |
| 2 | import { Glob } from 'bun' | |
| 3 | import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' | |
| 4 | import { localeContentCandidates, localizeUrl } from './i18n' | |
| 5 | import { decodeHtmlEntities, escapeHtmlAttribute } from './html-entities' | |
| 6 | ||
| 7 | export interface MarkdownPage { | |
| 8 | readonly sourceFile: string | |
| 9 | readonly relativePath: string | |
| 10 | readonly locale: string | |
| 11 | } | |
| 12 | ||
| 13 | export interface MarkdownPageCatalog { | |
| 14 | readonly pages: readonly MarkdownPage[] | |
| 15 | findPage: (locale: string, path: string) => MarkdownPage | undefined | |
| 16 | resolveLink: (page: MarkdownPage, destination: string) => string | |
| 17 | } | |
| 18 | ||
| 19 | export interface MarkdownPageContext { | |
| 20 | readonly page: MarkdownPage | |
| 21 | readonly catalog: MarkdownPageCatalog | |
| 22 | } | |
| 23 | ||
| 24 | /** Per-render boundaries for authored content expanded from another file. */ | |
| 25 | export type MarkdownLinkOrigins = Map<string, MarkdownPage | null> | |
| 26 | ||
| 27 | export 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 | ||
| 34 | function encodePath(path: string): string { | |
| 35 | return path.split('/').map(segment => encodeURIComponent(segment)).join('/') | |
| 36 | } | |
| 37 | ||
| 38 | function routeFor(path: string): string { | |
| 39 | const route = path === 'index' ? '' : path.replace(/\/index$/, '/') | |
| 40 | return `/${encodePath(route)}` | |
| 41 | } | |
| 42 | ||
| 43 | function 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. */ | |
| 49 | export 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. */ | |
| 138 | export 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 | } | |
| @@ -1,5 +1,8 @@ | ||
| 1 | import type { MarkdownLinkOrigins, MarkdownPageContext } from './markdown-pages' | |
| 2 | import { createMarkdownPageCatalog, findMarkdownFiles, rewriteMarkdownSourceLinks } from './markdown-pages' | |
| 1 | 3 | import type { BunPressConfig, Frontmatter, NavItem, SidebarItem } from './types' |
| 2 | 4 | import { Glob, YAML } from 'bun' |
| 5 | import { randomUUID } from 'node:crypto' | |
| 3 | 6 | import { stat } from 'node:fs/promises' |
| 4 | 7 | import { join, resolve } from 'node:path' |
| 5 | 8 | import process from 'node:process' |
| @@ -13,7 +16,6 @@ import { | ||
| 13 | 16 | generateHreflangTags, |
| 14 | 17 | generateLocaleDetectionScript, |
| 15 | 18 | loadI18nTranslations, |
| 16 | localeContentCandidates, | |
| 17 | 19 | localeLabel, |
| 18 | 20 | localizeUrl, |
| 19 | 21 | resolveI18nConfig, |
| @@ -28,6 +30,9 @@ import { messagePreviewCSS, renderMessagePreview } from './message-previews' | ||
| 28 | 30 | import { getThemeCSS } from './themes' |
| 29 | 31 | import { buildTocHierarchy, defaultTocConfig, extractHeadings, filterHeadings, generateInlineTocHtml, generateSlug, markdownHeadingText, reserveUniqueSlug } from './toc' |
| 30 | 32 | import { siteUrl } from './site-url' |
| 33 | import { decodeHtmlEntities, escapeHtmlAttribute } from './html-entities' | |
| 34 | ||
| 35 | export { decodeHtmlEntities } from './html-entities' | |
| 31 | 36 | |
| 32 | 37 | /** |
| 33 | 38 | * Generate sidebar HTML from BunPress config. |
| @@ -236,30 +241,6 @@ function generateSocialLinks(config: BunPressConfig): string { | ||
| 236 | 241 | .join('\n ') |
| 237 | 242 | } |
| 238 | 243 | |
| 239 | /** | |
| 240 | * Text from HTML back to text: `Raster & 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;` in the tab. | |
| 243 | */ | |
| 244 | export 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 | ||
| 255 | function escapeHtmlAttribute(value: string): string { | |
| 256 | return String(value ?? '') | |
| 257 | .replace(/&/g, '&') | |
| 258 | .replace(/</g, '<') | |
| 259 | .replace(/>/g, '>') | |
| 260 | .replace(/"/g, '"') | |
| 261 | } | |
| 262 | ||
| 263 | 244 | /** |
| 264 | 245 | * Extract headings from HTML content and render the page outline in both of |
| 265 | 246 | * its forms. |
| @@ -2552,7 +2533,7 @@ async function processCodeImports(content: string, rootDir: string): Promise<str | ||
| 2552 | 2533 | * Process markdown file inclusion |
| 2553 | 2534 | * Syntax: <!--@include: ./filepath.md--> or <!--@include: ./filepath.md{10-20}--> or <!--@include: ./filepath.md{#region}--> |
| 2554 | 2535 | */ |
| 2555 | async function processMarkdownIncludes(content: string, rootDir: string, processedFiles = new Set<string>()): Promise<string> { | |
| 2536 | async function processMarkdownIncludes(content: string, rootDir: string, processedFiles = new Set<string>(), pageContext?: MarkdownPageContext, origins?: MarkdownLinkOrigins): Promise<string> { | |
| 2556 | 2537 | const includeRegex = /<!--@include:\s*([^\s{]+)(?:\{(\d+)-(\d+)\}|\{#([\w-]+)\})?\s*-->/g |
| 2557 | 2538 | const matches = Array.from(content.matchAll(includeRegex)) |
| 2558 | 2539 | |
| @@ -2561,6 +2542,12 @@ async function processMarkdownIncludes(content: string, rootDir: string, process | ||
| 2561 | 2542 | const [fullMatch, filepath, startLine, endLine, regionName] = match |
| 2562 | 2543 | |
| 2563 | 2544 | 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 | } | |
| 2564 | 2551 | // Resolve file path relative to root directory |
| 2565 | 2552 | const { join, resolve } = await import('node:path') |
| 2566 | 2553 | const fullPath = resolve(join(rootDir, filepath)) |
| @@ -2613,11 +2600,22 @@ async function processMarkdownIncludes(content: string, rootDir: string, process | ||
| 2613 | 2600 | newProcessedFiles.add(fullPath) |
| 2614 | 2601 | |
| 2615 | 2602 | // 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 | } | |
| 2617 | 2613 | |
| 2618 | 2614 | result = result.slice(0, match.index) + includedContent + result.slice(match.index! + fullMatch.length) |
| 2619 | 2615 | } |
| 2620 | 2616 | catch (error) { |
| 2617 | if (pageContext) | |
| 2618 | throw error | |
| 2621 | 2619 | console.error(`Error including markdown from ${filepath}:`, error) |
| 2622 | 2620 | } |
| 2623 | 2621 | } |
| @@ -3066,7 +3064,7 @@ function postProcessHeadings(html: string, customAnchors: Map<string, string[]>) | ||
| 3066 | 3064 | * Convert markdown to HTML using Bun's built-in markdown parser |
| 3067 | 3065 | * See: https://bun.com/docs/runtime/markdown |
| 3068 | 3066 | */ |
| 3069 | export async function markdownToHtml(markdown: string, rootDir: string = './docs'): Promise<{ html: string, frontmatter: any }> { | |
| 3067 | export async function markdownToHtml(markdown: string, rootDir: string = './docs', pageContext?: MarkdownPageContext): Promise<{ html: string, frontmatter: any }> { | |
| 3070 | 3068 | // Parse frontmatter |
| 3071 | 3069 | const { frontmatter, content } = parseFrontmatter(markdown) |
| 3072 | 3070 | |
| @@ -3077,7 +3075,7 @@ export async function markdownToHtml(markdown: string, rootDir: string = './docs | ||
| 3077 | 3075 | const heroHtml = await generateHero(frontmatter.hero) |
| 3078 | 3076 | const featuresHtml = await generateFeatures(frontmatter.features) |
| 3079 | 3077 | const bodyHtml = content.trim() |
| 3080 | ? await renderMarkdownBody(content, frontmatter, rootDir) | |
| 3078 | ? await renderMarkdownBody(content, frontmatter, rootDir, pageContext) | |
| 3081 | 3079 | : '' |
| 3082 | 3080 | const body = bodyHtml |
| 3083 | 3081 | ? `<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 | ||
| 3088 | 3086 | } |
| 3089 | 3087 | } |
| 3090 | 3088 | |
| 3091 | const html = await renderMarkdownBody(content, frontmatter, rootDir) | |
| 3089 | const html = await renderMarkdownBody(content, frontmatter, rootDir, pageContext) | |
| 3092 | 3090 | return { html, frontmatter } |
| 3093 | 3091 | } |
| 3094 | 3092 | |
| @@ -3098,7 +3096,8 @@ export async function markdownToHtml(markdown: string, rootDir: string = './docs | ||
| 3098 | 3096 | * highlighting, and HTML post-processing. Shared by every layout (doc, page, |
| 3099 | 3097 | * and the body of a home page). |
| 3100 | 3098 | */ |
| 3101 | async function renderMarkdownBody(content: string, frontmatter: any, rootDir: string): Promise<string> { | |
| 3099 | async function renderMarkdownBody(content: string, frontmatter: any, rootDir: string, pageContext?: MarkdownPageContext): Promise<string> { | |
| 3100 | const includeOrigins: MarkdownLinkOrigins = new Map() | |
| 3102 | 3101 | // Resolve feature toggles from config (all default to true) |
| 3103 | 3102 | const features = (config as BunPressConfig).markdown?.features |
| 3104 | 3103 | const featureEnabled = (key: keyof import('./types').MarkdownFeaturesConfig): boolean => { |
| @@ -3129,7 +3128,7 @@ async function renderMarkdownBody(content: string, frontmatter: any, rootDir: st | ||
| 3129 | 3128 | // reason: they are all plain-text patterns, and a docs page is full of |
| 3130 | 3129 | // examples of them. |
| 3131 | 3130 | 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)) | |
| 3133 | 3132 | : content |
| 3134 | 3133 | |
| 3135 | 3134 | // Build the stx render context: frontmatter + global data files (.data/*.json, |
| @@ -3242,13 +3241,47 @@ async function renderMarkdownBody(content: string, frontmatter: any, rootDir: st | ||
| 3242 | 3241 | // See: https://bun.com/docs/runtime/markdown |
| 3243 | 3242 | // markdown.parserOptions is merged last so a site can turn a GFM extension |
| 3244 | 3243 | // off, or enable one this default set does not cover. |
| 3245 | let finalHtml = Bun.markdown.html(processedContent, { | |
| 3244 | const parserOptions = { | |
| 3246 | 3245 | tables: true, |
| 3247 | 3246 | strikethrough: true, |
| 3248 | 3247 | tasklists: true, |
| 3249 | 3248 | autolinks: true, |
| 3250 | 3249 | ...((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) | |
| 3252 | 3285 | |
| 3253 | 3286 | // Restore code block placeholders with actual highlighted HTML |
| 3254 | 3287 | for (const [placeholder, codeHtml] of codeBlockMap) { |
| @@ -3367,24 +3400,22 @@ export async function startServer(options: { | ||
| 3367 | 3400 | // With i18n on, `/es/guide` resolves the Spanish copy first and the |
| 3368 | 3401 | // default-locale copy last, so an untranslated page still renders. |
| 3369 | 3402 | 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 | }) | |
| 3386 | 3414 | } |
| 3387 | 3415 | } |
| 3416 | catch { | |
| 3417 | return new Response('Documentation page could not be rendered', { status: 500 }) | |
| 3418 | } | |
| 3388 | 3419 | |
| 3389 | 3420 | // 404 response |
| 3390 | 3421 | return new Response( |
| @@ -0,0 +1,454 @@ | ||
| 1 | import type { BunPressConfig } from '../packages/bunpress/src/types' | |
| 2 | import { afterEach, expect, test } from 'bun:test' | |
| 3 | import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { dirname, join } from 'node:path' | |
| 6 | import { buildDocs } from '../packages/bunpress/bin/cli' | |
| 7 | import { decodeHtmlEntities, startServer } from '../packages/bunpress/src/serve' | |
| 8 | ||
| 9 | const temporary: string[] = [] | |
| 10 | const 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 | ||
| 23 | async 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 | '', | |
| 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 | ||
| 65 | afterEach(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 | ||
| 71 | async 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&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 | ||
| 89 | test('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 | ||
| 100 | test('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 | ||
| 117 | test('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 | ||
| 134 | test('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 | ||
| 141 | test('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 | ||
| 169 | test('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 | ||
| 201 | test('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 | ||
| 221 | test('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 | ||
| 246 | test('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 | ||
| 261 | test('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 | ||
| 279 | test('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 | ||
| 306 | test('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 | ||
| 342 | test('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 | ||
| 372 | test('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 | ||
| 405 | test('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 | ||
| 439 | test('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) | |