diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index b9a1837b9a10..253755b2b35f 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -740,6 +740,10 @@ Disables environment variable injection entirely. Specifies the type of sourcemap to generate. +`Bun.build` also accepts a boolean. `false` means `"none"`. `true` means `"linked"` when `outdir` is set and `"inline"` when it is not. + +`Bun.build` does not need `outdir` for `"linked"` or `"external"`. It returns each sourcemap in [`outputs`](#outputs), and without `outdir` it writes nothing to disk. A build with `compile` is different: see [standalone executables](/compiler/executables). + ```ts title="build.ts" icon="/icons/typescript.svg" @@ -1226,6 +1230,8 @@ A map of file extensions to built-in loader names. Use this to customize how cer A banner added to the final bundle. This can be a directive like `"use client"` for React, or a comment block such as a license. +Bun adds the banner to every JavaScript entry point and chunk. It does not add it to CSS or HTML. + ```ts title="build.ts" icon="/icons/typescript.svg" @@ -1247,6 +1253,8 @@ A banner added to the final bundle. This can be a directive like `"use client"` A footer added to the final bundle. This can be a comment block for a license or a fun easter egg. +Bun adds the footer to every JavaScript entry point and chunk. It does not add it to CSS or HTML. + ```ts title="build.ts" icon="/icons/typescript.svg" @@ -1886,7 +1894,7 @@ interface BuildConfig { publicPath?: string; define?: Record; loader?: { [k in string]: Loader }; - sourcemap?: "none" | "linked" | "inline" | "external" | boolean; // default: "none", true -> "inline" + sourcemap?: "none" | "linked" | "inline" | "external" | boolean; // default: "none", true -> "linked" with outdir, "inline" without /** * package.json `exports` conditions used when resolving imports * @@ -1964,12 +1972,16 @@ interface BuildConfig { }; /** * Add a banner to the bundled code such as "use client"; + * + * Added to every JavaScript entry point and chunk, not to CSS or HTML. */ banner?: string; /** * Add a footer to the bundled code such as a comment block like * * `// made with bun!` + * + * Added to every JavaScript entry point and chunk, not to CSS or HTML. */ footer?: string; diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 8279a41e75f7..3a0c8053ae66 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3404,17 +3404,28 @@ declare module "bun" { * - `"none"` - No source maps are generated * - `"linked"` - A separate `*.ext.map` file is generated alongside each * `*.ext` file. A `//# sourceMappingURL` comment is added to the output - * file to link the two. Requires `outdir` to be set. + * file to link the two. * - `"inline"` - an inline source map is appended to the output file. - * - `"external"` - Generate a separate source map file for each input file. - * No `//# sourceMappingURL` comment is added to the output file. + * - `"external"` - Generate a separate source map file for each JavaScript + * entry point and chunk. No `//# sourceMappingURL` comment is added to + * the output file. * - * `true` and `false` are aliases for `"inline"` and `"none"`, respectively. + * Only JavaScript entry points and chunks get a source map. CSS and HTML + * do not. + * + * Each `"linked"` or `"external"` map is a separate {@link BuildArtifact} + * in `outputs` with `kind: "sourcemap"`. + * + * In a build without `compile`: + * - `true` is an alias for `"linked"` when `outdir` is set, and for + * `"inline"` when it is not. `false` is an alias for `"none"`. + * - Without `outdir`, Bun does not write a `"linked"` or `"external"` map + * to disk. * * @default "none" * - * @see {@link outdir} required for `"linked"` maps * @see {@link publicPath} to customize the base url of linked source maps + * @see [Standalone executables](https://bun.com/docs/compiler/executables) for source maps with `compile` */ sourcemap?: "none" | "linked" | "inline" | "external" | boolean; @@ -3549,6 +3560,9 @@ declare module "bun" { /** * Add a banner to the bundled code such as "use client"; + * + * The banner is added to every JavaScript entry point and chunk. It is + * not added to CSS or HTML. */ banner?: string; @@ -3556,6 +3570,9 @@ declare module "bun" { * Add a footer to the bundled code such as a comment block like * * `// made with bun!` + * + * The footer is added to every JavaScript entry point and chunk. It is + * not added to CSS or HTML. */ footer?: string; diff --git a/test/bundler/bun-build-jsdoc.test.ts b/test/bundler/bun-build-jsdoc.test.ts new file mode 100644 index 000000000000..101448d76f8f --- /dev/null +++ b/test/bundler/bun-build-jsdoc.test.ts @@ -0,0 +1,150 @@ +// The API reference for `Bun.build` is generated from the JSDoc in bun.d.ts. +// Each test runs a documented option, then checks that the JSDoc of that +// option describes what the build did. +import { describe, expect, test } from "bun:test"; +import { tempDir } from "harness"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const dts = readFileSync(join(import.meta.dir, "../../packages/bun-types/bun.d.ts"), "utf8"); + +/** The JSDoc block directly above `member?:` in bun.d.ts, as one line of prose. */ +function jsdocOf(member: string): string { + const decl = dts.search(new RegExp(`^ +${member}\\?: `, "m")); + expect(decl).toBeGreaterThan(-1); + const start = dts.lastIndexOf("/**", decl); + const end = dts.indexOf("*/", start); + expect(dts.slice(end + 2, decl).trim()).toBe(""); + return dts + .slice(start + 3, end) + .replace(/^\s*\* ?/gm, "") + .replace(/\s+/g, " ") + .trim(); +} + +/** Which `sourcemap` mode the `//# sourceMappingURL` comment of an output is. */ +function sourcemapCommentKind(text: string): "inline" | "linked" | "none" { + if (text.includes("//# sourceMappingURL=data:")) return "inline"; + if (text.includes("//# sourceMappingURL=")) return "linked"; + return "none"; +} + +const extensionOf = (path: string) => path.slice(path.lastIndexOf(".")); + +// With `splitting`, this page builds to one output of each type: a JavaScript +// entry point, a JavaScript chunk, a CSS file and an HTML file. +const page = { + "index.html": ``, + "index.js": `import("./lazy.js").then(m => console.log(m.default));`, + "lazy.js": `export default "lazy";`, + "style.css": `a { color: red; }`, +}; +const outputTypes = ["CSS", "HTML", "JavaScript"]; + +describe.concurrent("BuildConfig JSDoc matches the runtime", () => { + test("sourcemap: what `true` is an alias for", async () => { + using dir = tempDir("jsdoc-sourcemap-true", page); + const config = { entrypoints: [join(String(dir), "index.html")], splitting: true, sourcemap: true }; + + const observed: string[] = []; + for (const build of [await Bun.build({ ...config, outdir: join(String(dir), "out") }), await Bun.build(config)]) { + const scripts = build.outputs.filter(output => extensionOf(output.path) === ".js"); + expect(scripts.map(output => output.kind).sort()).toEqual(["chunk", "entry-point"]); + const kinds = await Promise.all(scripts.map(async output => sourcemapCommentKind(await output.text()))); + observed.push(...new Set(kinds)); + } + expect(observed).toEqual(["linked", "inline"]); + + const sentences = jsdocOf("sourcemap") + .split(/(?<=\.)\s+/) + .filter(sentence => sentence.includes("`true`")); + const documented = sentences.flatMap(sentence => + [...sentence.matchAll(/`"(none|linked|inline|external)"`/g)].map(match => match[1]), + ); + expect(documented).toEqual(expect.arrayContaining(observed)); + // The two builds differ only in `outdir`, so the JSDoc has to say that it decides. + expect(sentences.join(" ")).toContain("`outdir`"); + }); + + test('sourcemap: "linked" and "external" without outdir', async () => { + using dir = tempDir("jsdoc-sourcemap-no-outdir", page); + const sources = readdirSync(String(dir)).sort(); + + for (const sourcemap of ["linked", "external"] as const) { + const build = await Bun.build({ entrypoints: [join(String(dir), "index.html")], splitting: true, sourcemap }); + expect(build.success).toBe(true); + + const maps = build.outputs.filter(output => output.kind === "sourcemap").map(output => output.path); + // Without `outdir` the paths are relative to the cwd and hold a content + // hash, so a file at one of them can only come from this build. + expect(maps.filter(path => existsSync(path))).toEqual([]); + const observed = await Promise.all( + build.outputs + .filter(output => output.kind !== "sourcemap") + .map(async output => ({ + ext: extensionOf(output.path), + kind: output.kind, + map: maps.includes(output.path + ".map"), + comment: sourcemapCommentKind(await output.text()), + })), + ); + observed.sort((a, b) => (a.ext + a.kind).localeCompare(b.ext + b.kind)); + const comment = sourcemap === "linked" ? "linked" : "none"; + expect({ sourcemap, maps: maps.length, observed }).toEqual({ + sourcemap, + maps: 2, + observed: [ + { ext: ".css", kind: "asset", map: false, comment: "none" }, + { ext: ".html", kind: "entry-point", map: false, comment: "none" }, + { ext: ".js", kind: "chunk", map: true, comment }, + { ext: ".js", kind: "entry-point", map: true, comment }, + ], + }); + } + expect(readdirSync(String(dir)).sort()).toEqual(sources); + + const doc = jsdocOf("sourcemap"); + expect(doc).not.toMatch(/requires? `outdir`|outdir\}? (is )?required/i); + expect(doc).toContain('`kind: "sourcemap"`'); + // Only one of the three types of output file got a map, so the JSDoc has + // to name all three. + expect(outputTypes.filter(type => doc.includes(type))).toEqual(outputTypes); + }); + + test("banner and footer: JavaScript outputs only", async () => { + using dir = tempDir("jsdoc-banner-footer", page); + const build = await Bun.build({ + entrypoints: [join(String(dir), "index.html")], + splitting: true, + banner: "/* BANNER */", + footer: "/* FOOTER */", + }); + expect(build.success).toBe(true); + + const observed = await Promise.all( + build.outputs.map(async output => { + const text = await output.text(); + return { + ext: extensionOf(output.path), + kind: output.kind, + banner: text.includes("/* BANNER */"), + footer: text.includes("/* FOOTER */"), + }; + }), + ); + observed.sort((a, b) => (a.ext + a.kind).localeCompare(b.ext + b.kind)); + expect(observed).toEqual([ + { ext: ".css", kind: "asset", banner: false, footer: false }, + { ext: ".html", kind: "entry-point", banner: false, footer: false }, + { ext: ".js", kind: "chunk", banner: true, footer: true }, + { ext: ".js", kind: "entry-point", banner: true, footer: true }, + ]); + + // The build made three types of output file and treated them differently, + // so the JSDoc has to name all three. + for (const member of ["banner", "footer"]) { + const doc = jsdocOf(member); + expect({ member, named: outputTypes.filter(type => doc.includes(type)) }).toEqual({ member, named: outputTypes }); + } + }); +});