Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/bundler/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<Tabs>
<Tab title="JavaScript">
```ts title="build.ts" icon="/icons/typescript.svg"
Expand Down Expand Up @@ -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.

<Tabs>
<Tab title="JavaScript">
```ts title="build.ts" icon="/icons/typescript.svg"
Expand All @@ -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.

<Tabs>
<Tab title="JavaScript">
```ts title="build.ts" icon="/icons/typescript.svg"
Expand Down Expand Up @@ -1886,7 +1894,7 @@ interface BuildConfig {
publicPath?: string;
define?: Record<string, string>;
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
*
Expand Down Expand Up @@ -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;

Expand Down
27 changes: 22 additions & 5 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -3549,13 +3560,19 @@ 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;

/**
* 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;

Expand Down
150 changes: 150 additions & 0 deletions test/bundler/bun-build-jsdoc.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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": `<!doctype html><html><head><link rel="stylesheet" href="./style.css"><script type="module" src="./index.js"></script></head><body></body></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"];

Comment thread
robobun marked this conversation as resolved.
Resolved
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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
robobun marked this conversation as resolved.
Resolved
// 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);
Comment thread
robobun marked this conversation as resolved.
Resolved

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 });
}
});
});
Loading