tonnage 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/bin/tonnage.bin.js +3 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +62 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/run-DIk7L56w.js +199 -0
- package/dist/run-DIk7L56w.js.map +1 -0
- package/examples/recipes/my-package.d.ts +9 -0
- package/examples/recipes/react-app.ts +4 -0
- package/examples/tonnage.config.ts +19 -0
- package/package.json +53 -0
- package/src/cli.ts +91 -0
- package/src/define-config.ts +5 -0
- package/src/index.ts +18 -0
- package/src/measure.ts +96 -0
- package/src/readme.ts +48 -0
- package/src/report.ts +206 -0
- package/src/run.ts +28 -0
- package/src/types.ts +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeremy Banka
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# tonnage
|
|
2
|
+
|
|
3
|
+
Keep exact bundle size tables in a package README and fail CI when the checked-in
|
|
4
|
+
numbers drift.
|
|
5
|
+
|
|
6
|
+
The tool bundles real public package imports with esbuild, minifies the result,
|
|
7
|
+
and reports JavaScript bytes before and after level-9 gzip. Public-module rows
|
|
8
|
+
retain every runtime export; recipe entry files can select named exports and
|
|
9
|
+
tree-shake the rest. Declarations, source maps, CSS, and other assets are not
|
|
10
|
+
counted. Peer dependencies stay external, and shared modules are counted once
|
|
11
|
+
per bundle.
|
|
12
|
+
|
|
13
|
+
## Configure
|
|
14
|
+
|
|
15
|
+
Add the generated-section comments to the target README, then create a config
|
|
16
|
+
beside the package manifest. The complete configuration exhibit is in
|
|
17
|
+
[examples/tonnage.config.ts](./examples/tonnage.config.ts).
|
|
18
|
+
Each recipe points to a reviewable entry file such as
|
|
19
|
+
[examples/recipes/react-app.ts](./examples/recipes/react-app.ts). Use named
|
|
20
|
+
imports in these files to represent the runtime surface a real application uses;
|
|
21
|
+
unused package exports are tree-shaken.
|
|
22
|
+
|
|
23
|
+
The default generated-section marker is `default`; the example uses `my-package`.
|
|
24
|
+
The opening and closing comments use the forms
|
|
25
|
+
`tonnage:my-package:start` and `tonnage:my-package:end`.
|
|
26
|
+
|
|
27
|
+
## Run
|
|
28
|
+
|
|
29
|
+
Run `tonnage write` during development to update the README. Run
|
|
30
|
+
`tonnage check` in CI to recompute the same report without writing and exit
|
|
31
|
+
nonzero when the README is stale. The aliases `make` and `test` are also
|
|
32
|
+
available for manifest-style scripts.
|
|
33
|
+
|
|
34
|
+
By default the tool reads `package.json`, writes `README.md`, and measures every
|
|
35
|
+
public export except `./package.json`. Paths are resolved relative to the config
|
|
36
|
+
file. Use `exports.include` or `exports.exclude` to select subpaths, recipe entry
|
|
37
|
+
files to describe realistic runtime boundaries, and `external` for non-peer
|
|
38
|
+
imports that the package intentionally leaves to its consumers.
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { t as runTonnage } from "./run-DIk7L56w.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
//#region src/cli.ts
|
|
7
|
+
const DEFAULT_CONFIG_FILES = [
|
|
8
|
+
`tonnage.config.ts`,
|
|
9
|
+
`tonnage.config.mjs`,
|
|
10
|
+
`tonnage.config.js`
|
|
11
|
+
];
|
|
12
|
+
async function main() {
|
|
13
|
+
const command = process.argv[2];
|
|
14
|
+
const mode = parseMode(command);
|
|
15
|
+
const configPath = await findConfig(process.argv[3]);
|
|
16
|
+
const config = await loadConfig(configPath);
|
|
17
|
+
const result = await runTonnage(config, {
|
|
18
|
+
configDirectory: path.dirname(configPath),
|
|
19
|
+
mode
|
|
20
|
+
});
|
|
21
|
+
if (mode === `check` && result.changed) {
|
|
22
|
+
process.stderr.write(`${path.relative(process.cwd(), result.readmePath)} is out of date. Run tonnage write.\n`);
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const readme = path.relative(process.cwd(), result.readmePath);
|
|
27
|
+
process.stdout.write(result.changed ? `Updated ${readme}.\n` : `${readme} is already up to date.\n`);
|
|
28
|
+
}
|
|
29
|
+
function parseMode(command) {
|
|
30
|
+
switch (command) {
|
|
31
|
+
case `check`:
|
|
32
|
+
case `test`: return `check`;
|
|
33
|
+
case `make`:
|
|
34
|
+
case `write`: return `write`;
|
|
35
|
+
case void 0:
|
|
36
|
+
default: throw new Error(`Usage: tonnage <write|check> [tonnage.config.ts]`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function findConfig(configArgument) {
|
|
40
|
+
if (configArgument) return path.resolve(configArgument);
|
|
41
|
+
for (const filename of DEFAULT_CONFIG_FILES) {
|
|
42
|
+
const candidate = path.resolve(filename);
|
|
43
|
+
try {
|
|
44
|
+
await fs.access(candidate);
|
|
45
|
+
return candidate;
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Could not find a tonnage config (${DEFAULT_CONFIG_FILES.join(`, `)}).`);
|
|
49
|
+
}
|
|
50
|
+
async function loadConfig(configPath) {
|
|
51
|
+
const imported = await import(pathToFileURL(configPath).href);
|
|
52
|
+
if (!imported.default || typeof imported.default !== `object`) throw new Error(`${configPath} must default-export a tonnage config.`);
|
|
53
|
+
return imported.default;
|
|
54
|
+
}
|
|
55
|
+
main().catch((error) => {
|
|
56
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
});
|
|
59
|
+
//#endregion
|
|
60
|
+
export {};
|
|
61
|
+
|
|
62
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport fs from \"node:fs/promises\"\nimport path from \"node:path\"\nimport { pathToFileURL } from \"node:url\"\n\nimport { runTonnage } from \"./run.ts\"\nimport type { TonnageConfig, TonnageMode } from \"./types.ts\"\n\nconst DEFAULT_CONFIG_FILES = [\n\t`tonnage.config.ts`,\n\t`tonnage.config.mjs`,\n\t`tonnage.config.js`,\n]\n\nasync function main(): Promise<void> {\n\tconst command = process.argv[2]\n\tconst mode = parseMode(command)\n\tconst configPath = await findConfig(process.argv[3])\n\tconst config = await loadConfig(configPath)\n\tconst result = await runTonnage(config, {\n\t\tconfigDirectory: path.dirname(configPath),\n\t\tmode,\n\t})\n\n\tif (mode === `check` && result.changed) {\n\t\tprocess.stderr.write(\n\t\t\t`${path.relative(process.cwd(), result.readmePath)} is out of date. Run tonnage write.\\n`,\n\t\t)\n\t\tprocess.exitCode = 1\n\t\treturn\n\t}\n\n\tconst readme = path.relative(process.cwd(), result.readmePath)\n\tprocess.stdout.write(\n\t\tresult.changed\n\t\t\t? `Updated ${readme}.\\n`\n\t\t\t: `${readme} is already up to date.\\n`,\n\t)\n}\n\nfunction parseMode(command: string | undefined): TonnageMode {\n\tswitch (command) {\n\t\tcase `check`:\n\t\tcase `test`:\n\t\t\treturn `check`\n\t\tcase `make`:\n\t\tcase `write`:\n\t\t\treturn `write`\n\t\tcase undefined:\n\t\tdefault:\n\t\t\tthrow new Error(`Usage: tonnage <write|check> [tonnage.config.ts]`)\n\t}\n}\n\nasync function findConfig(configArgument: string | undefined): Promise<string> {\n\tif (configArgument) {\n\t\treturn path.resolve(configArgument)\n\t}\n\n\tfor (const filename of DEFAULT_CONFIG_FILES) {\n\t\tconst candidate = path.resolve(filename)\n\t\ttry {\n\t\t\tawait fs.access(candidate)\n\t\t\treturn candidate\n\t\t} catch {\n\t\t\t// Keep looking for the next supported config filename.\n\t\t}\n\t}\n\n\tthrow new Error(\n\t\t`Could not find a tonnage config (${DEFAULT_CONFIG_FILES.join(`, `)}).`,\n\t)\n}\n\nasync function loadConfig(configPath: string): Promise<TonnageConfig> {\n\tconst imported = (await import(pathToFileURL(configPath).href)) as {\n\t\tdefault?: unknown\n\t}\n\tif (!imported.default || typeof imported.default !== `object`) {\n\t\tthrow new Error(`${configPath} must default-export a tonnage config.`)\n\t}\n\treturn imported.default\n}\n\nmain().catch((error: unknown) => {\n\tprocess.stderr.write(\n\t\t`${error instanceof Error ? error.message : String(error)}\\n`,\n\t)\n\tprocess.exitCode = 1\n})\n"],"mappings":";;;;;;AASA,MAAM,uBAAuB;CAC5B;CACA;CACA;AACD;AAEA,eAAe,OAAsB;CACpC,MAAM,UAAU,QAAQ,KAAK;CAC7B,MAAM,OAAO,UAAU,OAAO;CAC9B,MAAM,aAAa,MAAM,WAAW,QAAQ,KAAK,EAAE;CACnD,MAAM,SAAS,MAAM,WAAW,UAAU;CAC1C,MAAM,SAAS,MAAM,WAAW,QAAQ;EACvC,iBAAiB,KAAK,QAAQ,UAAU;EACxC;CACD,CAAC;CAED,IAAI,SAAS,WAAW,OAAO,SAAS;EACvC,QAAQ,OAAO,MACd,GAAG,KAAK,SAAS,QAAQ,IAAI,GAAG,OAAO,UAAU,EAAE,sCACpD;EACA,QAAQ,WAAW;EACnB;CACD;CAEA,MAAM,SAAS,KAAK,SAAS,QAAQ,IAAI,GAAG,OAAO,UAAU;CAC7D,QAAQ,OAAO,MACd,OAAO,UACJ,WAAW,OAAO,OAClB,GAAG,OAAO,0BACd;AACD;AAEA,SAAS,UAAU,SAA0C;CAC5D,QAAQ,SAAR;EACC,KAAK;EACL,KAAK,QACJ,OAAO;EACR,KAAK;EACL,KAAK,SACJ,OAAO;EACR,KAAK,KAAA;EACL,SACC,MAAM,IAAI,MAAM,kDAAkD;CACpE;AACD;AAEA,eAAe,WAAW,gBAAqD;CAC9E,IAAI,gBACH,OAAO,KAAK,QAAQ,cAAc;CAGnC,KAAK,MAAM,YAAY,sBAAsB;EAC5C,MAAM,YAAY,KAAK,QAAQ,QAAQ;EACvC,IAAI;GACH,MAAM,GAAG,OAAO,SAAS;GACzB,OAAO;EACR,QAAQ,CAER;CACD;CAEA,MAAM,IAAI,MACT,oCAAoC,qBAAqB,KAAK,IAAI,EAAE,GACrE;AACD;AAEA,eAAe,WAAW,YAA4C;CACrE,MAAM,WAAY,MAAM,OAAO,cAAc,UAAU,CAAC,CAAC;CAGzD,IAAI,CAAC,SAAS,WAAW,OAAO,SAAS,YAAY,UACpD,MAAM,IAAI,MAAM,GAAG,WAAW,uCAAuC;CAEtE,OAAO,SAAS;AACjB;AAEA,KAAK,CAAC,CAAC,OAAO,UAAmB;CAChC,QAAQ,OAAO,MACd,GAAG,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAE,GAC3D;CACA,QAAQ,WAAW;AACpB,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
type BundlePlatform = `browser` | `neutral` | `node`;
|
|
3
|
+
type TonnageExports = {
|
|
4
|
+
exclude?: readonly string[];
|
|
5
|
+
include?: readonly string[];
|
|
6
|
+
};
|
|
7
|
+
type TonnageRecipe = {
|
|
8
|
+
entry: string;
|
|
9
|
+
external?: readonly string[];
|
|
10
|
+
name: string;
|
|
11
|
+
platform?: BundlePlatform;
|
|
12
|
+
};
|
|
13
|
+
type TonnageConfig = {
|
|
14
|
+
exports?: boolean | TonnageExports;
|
|
15
|
+
external?: readonly string[];
|
|
16
|
+
heading?: string;
|
|
17
|
+
marker?: string;
|
|
18
|
+
packageJson?: string;
|
|
19
|
+
platform?: BundlePlatform;
|
|
20
|
+
readme?: string;
|
|
21
|
+
recipes?: readonly TonnageRecipe[];
|
|
22
|
+
target?: string;
|
|
23
|
+
};
|
|
24
|
+
type BundleMeasurement = {
|
|
25
|
+
gzipBytes: number;
|
|
26
|
+
rawBytes: number;
|
|
27
|
+
};
|
|
28
|
+
type TonnageRow = BundleMeasurement & {
|
|
29
|
+
imports: readonly string[];
|
|
30
|
+
name: string;
|
|
31
|
+
};
|
|
32
|
+
type TonnageRecipeRow = BundleMeasurement & {
|
|
33
|
+
entry: string;
|
|
34
|
+
name: string;
|
|
35
|
+
};
|
|
36
|
+
type TonnageReport = {
|
|
37
|
+
exports: readonly TonnageRow[];
|
|
38
|
+
packageName: string;
|
|
39
|
+
recipes: readonly TonnageRecipeRow[];
|
|
40
|
+
};
|
|
41
|
+
type TonnageMode = `check` | `write`;
|
|
42
|
+
type TonnageRunResult = {
|
|
43
|
+
changed: boolean;
|
|
44
|
+
readmePath: string;
|
|
45
|
+
report: TonnageReport;
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/define-config.d.ts
|
|
49
|
+
declare function defineConfig(config: TonnageConfig): TonnageConfig;
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/measure.d.ts
|
|
52
|
+
type MeasureImportsOptions = {
|
|
53
|
+
external?: readonly string[] | undefined;
|
|
54
|
+
platform?: BundlePlatform | undefined;
|
|
55
|
+
resolveDirectory: string;
|
|
56
|
+
target?: string | undefined;
|
|
57
|
+
};
|
|
58
|
+
type MeasureEntryOptions = MeasureImportsOptions;
|
|
59
|
+
declare function measureEntry(entry: string, options: MeasureEntryOptions): Promise<BundleMeasurement>;
|
|
60
|
+
declare function measureImports(imports: readonly string[], options: MeasureImportsOptions): Promise<BundleMeasurement>;
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/readme.d.ts
|
|
63
|
+
declare function tonnageMarkers(marker?: string): {
|
|
64
|
+
end: string;
|
|
65
|
+
start: string;
|
|
66
|
+
};
|
|
67
|
+
declare function updateGeneratedSection(readme: string, markdown: string, marker?: string): string;
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/report.d.ts
|
|
70
|
+
declare function createTonnageReport(config: TonnageConfig, configDirectory: string): Promise<TonnageReport>;
|
|
71
|
+
declare function renderTonnageMarkdown(report: TonnageReport, config?: TonnageConfig): string;
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/run.d.ts
|
|
74
|
+
declare function runTonnage(config: TonnageConfig, options: {
|
|
75
|
+
configDirectory?: string;
|
|
76
|
+
mode: TonnageMode;
|
|
77
|
+
}): Promise<TonnageRunResult>;
|
|
78
|
+
//#endregion
|
|
79
|
+
export { type BundleMeasurement, type BundlePlatform, type MeasureEntryOptions, type MeasureImportsOptions, type TonnageConfig, type TonnageExports, type TonnageMode, type TonnageRecipe, type TonnageRecipeRow, type TonnageReport, type TonnageRow, type TonnageRunResult, createTonnageReport, defineConfig, measureEntry, measureImports, renderTonnageMarkdown, runTonnage, tonnageMarkers, updateGeneratedSection };
|
|
80
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/define-config.ts","../src/measure.ts","../src/readme.ts","../src/report.ts","../src/run.ts"],"mappings":";KAAY;KAEA;EACX;EACA;;KAGW;EACX;EACA;EACA;EACA,WAAW;;KAGA;EACX,oBAAoB;EACpB;EACA;EACA;EACA;EACA,WAAW;EACX;EACA,mBAAmB;EACnB;;KAGW;EACX;EACA;;KAGW,aAAa;EACxB;EACA;;KAGW,mBAAmB;EAC9B;EACA;;KAGW;EACX,kBAAkB;EAClB;EACA,kBAAkB;;KAGP;KAEA;EACX;EACA;EACA,QAAQ;;;;iBClDO,aAAa,QAAQ,gBAAgB;;;KCKzC;EACX;EACA,WAAW;EACX;EACA;;KAGW,sBAAsB;iBAEZ,aACrB,eACA,SAAS,sBACP,QAAQ;iBAOW,eACrB,4BACA,SAAS,wBACP,QAAQ;;;iBC7BK,eAAe;EAC9B;EACA;;iBASe,uBACf,gBACA,kBACA;;;iBCGqB,oBACrB,QAAQ,eACR,0BACE,QAAQ;iBAqDK,sBACf,QAAQ,eACR,SAAQ;;;iBCpEa,WACrB,QAAQ,eACR;EACC;EACA,MAAM;IAEL,QAAQ"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { a as updateGeneratedSection, i as tonnageMarkers, n as createTonnageReport, o as measureEntry, r as renderTonnageMarkdown, s as measureImports, t as runTonnage } from "./run-DIk7L56w.js";
|
|
2
|
+
//#region src/define-config.ts
|
|
3
|
+
function defineConfig(config) {
|
|
4
|
+
return config;
|
|
5
|
+
}
|
|
6
|
+
//#endregion
|
|
7
|
+
export { createTonnageReport, defineConfig, measureEntry, measureImports, renderTonnageMarkdown, runTonnage, tonnageMarkers, updateGeneratedSection };
|
|
8
|
+
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/define-config.ts"],"sourcesContent":["import type { TonnageConfig } from \"./types.ts\"\n\nexport function defineConfig(config: TonnageConfig): TonnageConfig {\n\treturn config\n}\n"],"mappings":";;AAEA,SAAgB,aAAa,QAAsC;CAClE,OAAO;AACR"}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { gzipSync } from "node:zlib";
|
|
3
|
+
import { build } from "esbuild";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
//#region src/measure.ts
|
|
6
|
+
async function measureEntry(entry, options) {
|
|
7
|
+
return measureBuild({ entryPoints: [path.resolve(options.resolveDirectory, entry)] }, options);
|
|
8
|
+
}
|
|
9
|
+
async function measureImports(imports, options) {
|
|
10
|
+
if (imports.length === 0) throw new Error(`A tonnage entry must import at least one module.`);
|
|
11
|
+
return measureBuild({ stdin: {
|
|
12
|
+
contents: imports.map((specifier, index) => `export * as entry${index} from ${JSON.stringify(specifier)}`).join(`\n`),
|
|
13
|
+
loader: `js`,
|
|
14
|
+
resolveDir: options.resolveDirectory,
|
|
15
|
+
sourcefile: `tonnage-entry.js`
|
|
16
|
+
} }, options);
|
|
17
|
+
}
|
|
18
|
+
async function measureBuild(entry, options) {
|
|
19
|
+
const platform = options.platform ?? `neutral`;
|
|
20
|
+
const outputFiles = [...(await build({
|
|
21
|
+
bundle: true,
|
|
22
|
+
charset: `utf8`,
|
|
23
|
+
external: [...options.external ?? [], ...platform === `browser` ? [] : [`node:*`]],
|
|
24
|
+
format: `esm`,
|
|
25
|
+
legalComments: `none`,
|
|
26
|
+
logLevel: `silent`,
|
|
27
|
+
minify: true,
|
|
28
|
+
outdir: `out`,
|
|
29
|
+
platform,
|
|
30
|
+
...entry,
|
|
31
|
+
target: options.target ?? `es2022`,
|
|
32
|
+
treeShaking: true,
|
|
33
|
+
write: false
|
|
34
|
+
})).outputFiles ?? []].filter((output) => output.path.endsWith(`.js`)).sort((left, right) => left.path.localeCompare(right.path));
|
|
35
|
+
if (outputFiles.length === 0) throw new Error(`The tonnage entry did not emit any runtime JavaScript.`);
|
|
36
|
+
const rawBytes = outputFiles.reduce((total, output) => total + output.contents.byteLength, 0);
|
|
37
|
+
return {
|
|
38
|
+
gzipBytes: outputFiles.reduce((total, output) => total + gzipSync(output.contents, { level: 9 }).byteLength, 0),
|
|
39
|
+
rawBytes
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/readme.ts
|
|
44
|
+
function tonnageMarkers(marker = `default`) {
|
|
45
|
+
validateMarker(marker);
|
|
46
|
+
return {
|
|
47
|
+
end: `<!-- tonnage:${marker}:end -->`,
|
|
48
|
+
start: `<!-- tonnage:${marker}:start -->`
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function updateGeneratedSection(readme, markdown, marker) {
|
|
52
|
+
const { end, start } = tonnageMarkers(marker);
|
|
53
|
+
const startIndex = readme.indexOf(start);
|
|
54
|
+
const endIndex = readme.indexOf(end);
|
|
55
|
+
if (startIndex === -1 || endIndex === -1) throw new Error(`README must contain exactly one ${start} and one ${end} marker.`);
|
|
56
|
+
if (readme.slice(startIndex + start.length).includes(start) || readme.slice(endIndex + end.length).includes(end)) throw new Error(`README must contain exactly one ${start} and one ${end} marker.`);
|
|
57
|
+
if (endIndex < startIndex) throw new Error(`${end} must appear after ${start}.`);
|
|
58
|
+
const contentStart = startIndex + start.length;
|
|
59
|
+
return `${readme.slice(0, contentStart)}\n\n${markdown.trim()}\n\n${readme.slice(endIndex)}`;
|
|
60
|
+
}
|
|
61
|
+
function validateMarker(marker) {
|
|
62
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(marker)) throw new Error(`Tonnage marker names may contain letters, numbers, dots, underscores, and hyphens.`);
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/report.ts
|
|
66
|
+
async function createTonnageReport(config, configDirectory) {
|
|
67
|
+
const packageJsonPath = path.resolve(configDirectory, config.packageJson ?? `package.json`);
|
|
68
|
+
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, `utf8`));
|
|
69
|
+
if (!packageJson.name) throw new Error(`${packageJsonPath} does not declare a package name.`);
|
|
70
|
+
const resolveDirectory = path.dirname(packageJsonPath);
|
|
71
|
+
const external = unique([...Object.keys(packageJson.peerDependencies ?? {}), ...config.external ?? []]);
|
|
72
|
+
const exportImports = selectExportImports(packageJson.name, packageJson.exports, config.exports);
|
|
73
|
+
const exportRows = await Promise.all(exportImports.map(async (specifier) => ({
|
|
74
|
+
...await measureImports([specifier], {
|
|
75
|
+
external,
|
|
76
|
+
platform: config.platform,
|
|
77
|
+
resolveDirectory,
|
|
78
|
+
target: config.target
|
|
79
|
+
}),
|
|
80
|
+
imports: [specifier],
|
|
81
|
+
name: specifier
|
|
82
|
+
})));
|
|
83
|
+
const recipeRows = await Promise.all((config.recipes ?? []).map(async (recipe) => ({
|
|
84
|
+
...await measureEntry(recipe.entry, {
|
|
85
|
+
external: unique([...external, ...recipe.external ?? []]),
|
|
86
|
+
platform: recipe.platform ?? config.platform,
|
|
87
|
+
resolveDirectory,
|
|
88
|
+
target: config.target
|
|
89
|
+
}),
|
|
90
|
+
entry: recipe.entry,
|
|
91
|
+
name: recipe.name
|
|
92
|
+
})));
|
|
93
|
+
return {
|
|
94
|
+
exports: exportRows,
|
|
95
|
+
packageName: packageJson.name,
|
|
96
|
+
recipes: recipeRows
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function renderTonnageMarkdown(report, config = {}) {
|
|
100
|
+
const lines = [
|
|
101
|
+
`## ${config.heading ?? `Bundle size`}`,
|
|
102
|
+
``,
|
|
103
|
+
`Public-module rows retain complete runtime export surfaces. Recipe rows bundle their reviewable entry files and tree-shake unused exports. Both report exact minified and level-9 gzip JavaScript byte counts; declarations, source maps, CSS, and other assets are excluded. Peer dependencies stay external, and shared modules are counted once per bundle.`
|
|
104
|
+
];
|
|
105
|
+
if (report.exports.length > 0) lines.push(``, `### Public modules (whole export surface)`, ``, ...markdownTable([
|
|
106
|
+
`Import`,
|
|
107
|
+
`Minified JS`,
|
|
108
|
+
`Gzip JS`
|
|
109
|
+
], report.exports.map((row) => [
|
|
110
|
+
code(row.name),
|
|
111
|
+
formatBytes(row.rawBytes),
|
|
112
|
+
formatBytes(row.gzipBytes)
|
|
113
|
+
]), [
|
|
114
|
+
`left`,
|
|
115
|
+
`right`,
|
|
116
|
+
`right`
|
|
117
|
+
]));
|
|
118
|
+
if (report.recipes.length > 0) lines.push(``, `### Representative runtimes (tree-shaken)`, ``, ...markdownTable([
|
|
119
|
+
`Recipe`,
|
|
120
|
+
`Entry`,
|
|
121
|
+
`Minified JS`,
|
|
122
|
+
`Gzip JS`
|
|
123
|
+
], report.recipes.map((row) => [
|
|
124
|
+
escapeCell(row.name),
|
|
125
|
+
code(row.entry),
|
|
126
|
+
formatBytes(row.rawBytes),
|
|
127
|
+
formatBytes(row.gzipBytes)
|
|
128
|
+
]), [
|
|
129
|
+
`left`,
|
|
130
|
+
`left`,
|
|
131
|
+
`right`,
|
|
132
|
+
`right`
|
|
133
|
+
]));
|
|
134
|
+
return lines.join(`\n`);
|
|
135
|
+
}
|
|
136
|
+
function selectExportImports(packageName, exportsValue, selection) {
|
|
137
|
+
if (selection === false) return [];
|
|
138
|
+
const exportKeys = getExportKeys(exportsValue);
|
|
139
|
+
const options = typeof selection === `object` ? selection : {};
|
|
140
|
+
const included = options.include ? [...options.include] : exportKeys.filter((exportKey) => exportKey !== `./package.json`);
|
|
141
|
+
const excluded = new Set(options.exclude ?? []);
|
|
142
|
+
return included.filter((exportKey) => !excluded.has(exportKey)).map((exportKey) => toPackageImport(packageName, exportKey));
|
|
143
|
+
}
|
|
144
|
+
function getExportKeys(exportsValue) {
|
|
145
|
+
if (typeof exportsValue === `string` || Array.isArray(exportsValue)) return [`.`];
|
|
146
|
+
if (!exportsValue || typeof exportsValue !== `object`) return [`.`];
|
|
147
|
+
const keys = Object.keys(exportsValue);
|
|
148
|
+
return keys.some((key) => key.startsWith(`.`)) ? keys : [`.`];
|
|
149
|
+
}
|
|
150
|
+
function toPackageImport(packageName, exportKey) {
|
|
151
|
+
if (exportKey === `.`) return packageName;
|
|
152
|
+
if (!exportKey.startsWith(`./`)) throw new Error(`Invalid package export key: ${exportKey}`);
|
|
153
|
+
return `${packageName}/${exportKey.slice(2)}`;
|
|
154
|
+
}
|
|
155
|
+
function formatBytes(bytes) {
|
|
156
|
+
return `${new Intl.NumberFormat(`en-US`).format(bytes)} B`;
|
|
157
|
+
}
|
|
158
|
+
function code(value) {
|
|
159
|
+
return `<code>${escapeCell(value)}</code>`;
|
|
160
|
+
}
|
|
161
|
+
function escapeCell(value) {
|
|
162
|
+
return value.replaceAll(`&`, `&`).replaceAll(`|`, `|`);
|
|
163
|
+
}
|
|
164
|
+
function unique(values) {
|
|
165
|
+
return [...new Set(values)];
|
|
166
|
+
}
|
|
167
|
+
function markdownTable(headings, rows, alignments) {
|
|
168
|
+
const widths = headings.map((heading, column) => Math.max(heading.length, ...rows.map((row) => row[column]?.length ?? 0), 3));
|
|
169
|
+
const renderRow = (cells) => `| ${cells.map((cell, column) => padCell(cell, widths[column] ?? 3, alignments[column] ?? `left`)).join(` | `)} |`;
|
|
170
|
+
const separator = widths.map((width, column) => alignments[column] === `right` ? `${`-`.repeat(width - 1)}:` : `-`.repeat(width));
|
|
171
|
+
return [
|
|
172
|
+
renderRow(headings),
|
|
173
|
+
renderRow(separator),
|
|
174
|
+
...rows.map(renderRow)
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
function padCell(cell, width, alignment) {
|
|
178
|
+
return alignment === `right` ? cell.padStart(width) : cell.padEnd(width);
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region src/run.ts
|
|
182
|
+
async function runTonnage(config, options) {
|
|
183
|
+
const configDirectory = path.resolve(options.configDirectory ?? process.cwd());
|
|
184
|
+
const readmePath = path.resolve(configDirectory, config.readme ?? `README.md`);
|
|
185
|
+
const oldReadme = await fs.readFile(readmePath, `utf8`);
|
|
186
|
+
const report = await createTonnageReport(config, configDirectory);
|
|
187
|
+
const newReadme = updateGeneratedSection(oldReadme, renderTonnageMarkdown(report, config), config.marker);
|
|
188
|
+
const changed = oldReadme !== newReadme;
|
|
189
|
+
if (options.mode === `write` && changed) await fs.writeFile(readmePath, newReadme);
|
|
190
|
+
return {
|
|
191
|
+
changed,
|
|
192
|
+
readmePath,
|
|
193
|
+
report
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
197
|
+
export { updateGeneratedSection as a, tonnageMarkers as i, createTonnageReport as n, measureEntry as o, renderTonnageMarkdown as r, measureImports as s, runTonnage as t };
|
|
198
|
+
|
|
199
|
+
//# sourceMappingURL=run-DIk7L56w.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-DIk7L56w.js","names":[],"sources":["../src/measure.ts","../src/readme.ts","../src/report.ts","../src/run.ts"],"sourcesContent":["import path from \"node:path\"\nimport { gzipSync } from \"node:zlib\"\n\nimport { build, type BuildOptions } from \"esbuild\"\n\nimport type { BundleMeasurement, BundlePlatform } from \"./types.ts\"\n\nexport type MeasureImportsOptions = {\n\texternal?: readonly string[] | undefined\n\tplatform?: BundlePlatform | undefined\n\tresolveDirectory: string\n\ttarget?: string | undefined\n}\n\nexport type MeasureEntryOptions = MeasureImportsOptions\n\nexport async function measureEntry(\n\tentry: string,\n\toptions: MeasureEntryOptions,\n): Promise<BundleMeasurement> {\n\treturn measureBuild(\n\t\t{ entryPoints: [path.resolve(options.resolveDirectory, entry)] },\n\t\toptions,\n\t)\n}\n\nexport async function measureImports(\n\timports: readonly string[],\n\toptions: MeasureImportsOptions,\n): Promise<BundleMeasurement> {\n\tif (imports.length === 0) {\n\t\tthrow new Error(`A tonnage entry must import at least one module.`)\n\t}\n\n\tconst entry = imports\n\t\t.map(\n\t\t\t(specifier, index) =>\n\t\t\t\t`export * as entry${index} from ${JSON.stringify(specifier)}`,\n\t\t)\n\t\t.join(`\\n`)\n\treturn measureBuild(\n\t\t{\n\t\t\tstdin: {\n\t\t\t\tcontents: entry,\n\t\t\t\tloader: `js`,\n\t\t\t\tresolveDir: options.resolveDirectory,\n\t\t\t\tsourcefile: `tonnage-entry.js`,\n\t\t\t},\n\t\t},\n\t\toptions,\n\t)\n}\n\nasync function measureBuild(\n\tentry: Pick<BuildOptions, `entryPoints` | `stdin`>,\n\toptions: MeasureEntryOptions,\n): Promise<BundleMeasurement> {\n\tconst platform = options.platform ?? `neutral`\n\n\tconst result = await build({\n\t\tbundle: true,\n\t\tcharset: `utf8`,\n\t\texternal: [\n\t\t\t...(options.external ?? []),\n\t\t\t...(platform === `browser` ? [] : [`node:*`]),\n\t\t],\n\t\tformat: `esm`,\n\t\tlegalComments: `none`,\n\t\tlogLevel: `silent`,\n\t\tminify: true,\n\t\toutdir: `out`,\n\t\tplatform,\n\t\t...entry,\n\t\ttarget: options.target ?? `es2022`,\n\t\ttreeShaking: true,\n\t\twrite: false,\n\t})\n\n\tconst outputFiles = [...(result.outputFiles ?? [])]\n\t\t.filter((output) => output.path.endsWith(`.js`))\n\t\t.sort((left, right) => left.path.localeCompare(right.path))\n\tif (outputFiles.length === 0) {\n\t\tthrow new Error(`The tonnage entry did not emit any runtime JavaScript.`)\n\t}\n\tconst rawBytes = outputFiles.reduce(\n\t\t(total, output) => total + output.contents.byteLength,\n\t\t0,\n\t)\n\tconst gzipBytes = outputFiles.reduce(\n\t\t(total, output) =>\n\t\t\ttotal + gzipSync(output.contents, { level: 9 }).byteLength,\n\t\t0,\n\t)\n\n\treturn { gzipBytes, rawBytes }\n}\n","export function tonnageMarkers(marker = `default`): {\n\tend: string\n\tstart: string\n} {\n\tvalidateMarker(marker)\n\treturn {\n\t\tend: `<!-- tonnage:${marker}:end -->`,\n\t\tstart: `<!-- tonnage:${marker}:start -->`,\n\t}\n}\n\nexport function updateGeneratedSection(\n\treadme: string,\n\tmarkdown: string,\n\tmarker?: string,\n): string {\n\tconst { end, start } = tonnageMarkers(marker)\n\tconst startIndex = readme.indexOf(start)\n\tconst endIndex = readme.indexOf(end)\n\n\tif (startIndex === -1 || endIndex === -1) {\n\t\tthrow new Error(\n\t\t\t`README must contain exactly one ${start} and one ${end} marker.`,\n\t\t)\n\t}\n\tif (\n\t\treadme.slice(startIndex + start.length).includes(start) ||\n\t\treadme.slice(endIndex + end.length).includes(end)\n\t) {\n\t\tthrow new Error(\n\t\t\t`README must contain exactly one ${start} and one ${end} marker.`,\n\t\t)\n\t}\n\tif (endIndex < startIndex) {\n\t\tthrow new Error(`${end} must appear after ${start}.`)\n\t}\n\n\tconst contentStart = startIndex + start.length\n\treturn `${readme.slice(0, contentStart)}\\n\\n${markdown.trim()}\\n\\n${readme.slice(endIndex)}`\n}\n\nfunction validateMarker(marker: string): void {\n\tif (!/^[a-z0-9][a-z0-9._-]*$/i.test(marker)) {\n\t\tthrow new Error(\n\t\t\t`Tonnage marker names may contain letters, numbers, dots, underscores, and hyphens.`,\n\t\t)\n\t}\n}\n","import fs from \"node:fs/promises\"\nimport path from \"node:path\"\n\nimport { measureEntry, measureImports } from \"./measure.ts\"\nimport type {\n\tTonnageConfig,\n\tTonnageExports,\n\tTonnageReport,\n\tTonnageRow,\n} from \"./types.ts\"\n\ntype PackageJson = {\n\texports?: unknown\n\tname?: string\n\tpeerDependencies?: Record<string, string>\n}\n\nexport async function createTonnageReport(\n\tconfig: TonnageConfig,\n\tconfigDirectory: string,\n): Promise<TonnageReport> {\n\tconst packageJsonPath = path.resolve(\n\t\tconfigDirectory,\n\t\tconfig.packageJson ?? `package.json`,\n\t)\n\tconst packageJson = JSON.parse(\n\t\tawait fs.readFile(packageJsonPath, `utf8`),\n\t) as PackageJson\n\tif (!packageJson.name) {\n\t\tthrow new Error(`${packageJsonPath} does not declare a package name.`)\n\t}\n\n\tconst resolveDirectory = path.dirname(packageJsonPath)\n\tconst peerDependencies = Object.keys(packageJson.peerDependencies ?? {})\n\tconst external = unique([...peerDependencies, ...(config.external ?? [])])\n\tconst exportImports = selectExportImports(\n\t\tpackageJson.name,\n\t\tpackageJson.exports,\n\t\tconfig.exports,\n\t)\n\n\tconst exportRows = await Promise.all(\n\t\texportImports.map(async (specifier): Promise<TonnageRow> => ({\n\t\t\t...(await measureImports([specifier], {\n\t\t\t\texternal,\n\t\t\t\tplatform: config.platform,\n\t\t\t\tresolveDirectory,\n\t\t\t\ttarget: config.target,\n\t\t\t})),\n\t\t\timports: [specifier],\n\t\t\tname: specifier,\n\t\t})),\n\t)\n\tconst recipeRows = await Promise.all(\n\t\t(config.recipes ?? []).map(async (recipe) => ({\n\t\t\t...(await measureEntry(recipe.entry, {\n\t\t\t\texternal: unique([...external, ...(recipe.external ?? [])]),\n\t\t\t\tplatform: recipe.platform ?? config.platform,\n\t\t\t\tresolveDirectory,\n\t\t\t\ttarget: config.target,\n\t\t\t})),\n\t\t\tentry: recipe.entry,\n\t\t\tname: recipe.name,\n\t\t})),\n\t)\n\n\treturn {\n\t\texports: exportRows,\n\t\tpackageName: packageJson.name,\n\t\trecipes: recipeRows,\n\t}\n}\n\nexport function renderTonnageMarkdown(\n\treport: TonnageReport,\n\tconfig: TonnageConfig = {},\n): string {\n\tconst lines = [\n\t\t`## ${config.heading ?? `Bundle size`}`,\n\t\t``,\n\t\t`Public-module rows retain complete runtime export surfaces. Recipe rows bundle their reviewable entry files and tree-shake unused exports. Both report exact minified and level-9 gzip JavaScript byte counts; declarations, source maps, CSS, and other assets are excluded. Peer dependencies stay external, and shared modules are counted once per bundle.`,\n\t]\n\n\tif (report.exports.length > 0) {\n\t\tlines.push(\n\t\t\t``,\n\t\t\t`### Public modules (whole export surface)`,\n\t\t\t``,\n\t\t\t...markdownTable(\n\t\t\t\t[`Import`, `Minified JS`, `Gzip JS`],\n\t\t\t\treport.exports.map((row) => [\n\t\t\t\t\tcode(row.name),\n\t\t\t\t\tformatBytes(row.rawBytes),\n\t\t\t\t\tformatBytes(row.gzipBytes),\n\t\t\t\t]),\n\t\t\t\t[`left`, `right`, `right`],\n\t\t\t),\n\t\t)\n\t}\n\n\tif (report.recipes.length > 0) {\n\t\tlines.push(\n\t\t\t``,\n\t\t\t`### Representative runtimes (tree-shaken)`,\n\t\t\t``,\n\t\t\t...markdownTable(\n\t\t\t\t[`Recipe`, `Entry`, `Minified JS`, `Gzip JS`],\n\t\t\t\treport.recipes.map((row) => [\n\t\t\t\t\tescapeCell(row.name),\n\t\t\t\t\tcode(row.entry),\n\t\t\t\t\tformatBytes(row.rawBytes),\n\t\t\t\t\tformatBytes(row.gzipBytes),\n\t\t\t\t]),\n\t\t\t\t[`left`, `left`, `right`, `right`],\n\t\t\t),\n\t\t)\n\t}\n\n\treturn lines.join(`\\n`)\n}\n\nfunction selectExportImports(\n\tpackageName: string,\n\texportsValue: unknown,\n\tselection: TonnageConfig[`exports`],\n): string[] {\n\tif (selection === false) {\n\t\treturn []\n\t}\n\n\tconst exportKeys = getExportKeys(exportsValue)\n\tconst options: TonnageExports = typeof selection === `object` ? selection : {}\n\tconst included = options.include\n\t\t? [...options.include]\n\t\t: exportKeys.filter((exportKey) => exportKey !== `./package.json`)\n\tconst excluded = new Set(options.exclude ?? [])\n\n\treturn included\n\t\t.filter((exportKey) => !excluded.has(exportKey))\n\t\t.map((exportKey) => toPackageImport(packageName, exportKey))\n}\n\nfunction getExportKeys(exportsValue: unknown): string[] {\n\tif (typeof exportsValue === `string` || Array.isArray(exportsValue)) {\n\t\treturn [`.`]\n\t}\n\tif (!exportsValue || typeof exportsValue !== `object`) {\n\t\treturn [`.`]\n\t}\n\n\tconst keys = Object.keys(exportsValue)\n\treturn keys.some((key) => key.startsWith(`.`)) ? keys : [`.`]\n}\n\nfunction toPackageImport(packageName: string, exportKey: string): string {\n\tif (exportKey === `.`) {\n\t\treturn packageName\n\t}\n\tif (!exportKey.startsWith(`./`)) {\n\t\tthrow new Error(`Invalid package export key: ${exportKey}`)\n\t}\n\treturn `${packageName}/${exportKey.slice(2)}`\n}\n\nfunction formatBytes(bytes: number): string {\n\treturn `${new Intl.NumberFormat(`en-US`).format(bytes)} B`\n}\n\nfunction code(value: string): string {\n\treturn `<code>${escapeCell(value)}</code>`\n}\n\nfunction escapeCell(value: string): string {\n\treturn value.replaceAll(`&`, `&`).replaceAll(`|`, `|`)\n}\n\nfunction unique(values: readonly string[]): string[] {\n\treturn [...new Set(values)]\n}\n\nfunction markdownTable(\n\theadings: readonly string[],\n\trows: readonly (readonly string[])[],\n\talignments: readonly (`left` | `right`)[],\n): string[] {\n\tconst widths = headings.map((heading, column) =>\n\t\tMath.max(heading.length, ...rows.map((row) => row[column]?.length ?? 0), 3),\n\t)\n\tconst renderRow = (cells: readonly string[]): string =>\n\t\t`| ${cells.map((cell, column) => padCell(cell, widths[column] ?? 3, alignments[column] ?? `left`)).join(` | `)} |`\n\tconst separator = widths.map((width, column) =>\n\t\talignments[column] === `right`\n\t\t\t? `${`-`.repeat(width - 1)}:`\n\t\t\t: `-`.repeat(width),\n\t)\n\n\treturn [renderRow(headings), renderRow(separator), ...rows.map(renderRow)]\n}\n\nfunction padCell(\n\tcell: string,\n\twidth: number,\n\talignment: `left` | `right`,\n): string {\n\treturn alignment === `right` ? cell.padStart(width) : cell.padEnd(width)\n}\n","import fs from \"node:fs/promises\"\nimport path from \"node:path\"\n\nimport { updateGeneratedSection } from \"./readme.ts\"\nimport { createTonnageReport, renderTonnageMarkdown } from \"./report.ts\"\nimport type { TonnageConfig, TonnageMode, TonnageRunResult } from \"./types.ts\"\n\nexport async function runTonnage(\n\tconfig: TonnageConfig,\n\toptions: {\n\t\tconfigDirectory?: string\n\t\tmode: TonnageMode\n\t},\n): Promise<TonnageRunResult> {\n\tconst configDirectory = path.resolve(options.configDirectory ?? process.cwd())\n\tconst readmePath = path.resolve(configDirectory, config.readme ?? `README.md`)\n\tconst oldReadme = await fs.readFile(readmePath, `utf8`)\n\tconst report = await createTonnageReport(config, configDirectory)\n\tconst markdown = renderTonnageMarkdown(report, config)\n\tconst newReadme = updateGeneratedSection(oldReadme, markdown, config.marker)\n\tconst changed = oldReadme !== newReadme\n\n\tif (options.mode === `write` && changed) {\n\t\tawait fs.writeFile(readmePath, newReadme)\n\t}\n\n\treturn { changed, readmePath, report }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineConfig, type TonnageConfig } from "tonnage"
|
|
2
|
+
|
|
3
|
+
// @exhibit-region start config
|
|
4
|
+
const config: TonnageConfig = defineConfig({
|
|
5
|
+
exports: {
|
|
6
|
+
exclude: [`./package.json`],
|
|
7
|
+
},
|
|
8
|
+
marker: `my-package`,
|
|
9
|
+
recipes: [
|
|
10
|
+
{
|
|
11
|
+
entry: `examples/recipes/react-app.ts`,
|
|
12
|
+
name: `React app`,
|
|
13
|
+
platform: `browser`,
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export default config
|
|
19
|
+
// @exhibit-region end config
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tonnage",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Keep deterministic bundle size reports in README files and CI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Jeremy Banka",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.18.0"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/jeremybanka/tonnage.git"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"examples",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"bin": {
|
|
26
|
+
"tonnage": "bin/tonnage.bin.js"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"import": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"main": "./dist/index.js",
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown",
|
|
38
|
+
"lint:eslint": "eslint",
|
|
39
|
+
"lint:oxlint": "oxlint",
|
|
40
|
+
"lint:types": "tsc",
|
|
41
|
+
"test:once": "vitest run"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"esbuild": "0.28.1"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "26.4.0",
|
|
48
|
+
"eslint": "10.9.1",
|
|
49
|
+
"tsdown": "0.22.14",
|
|
50
|
+
"typescript": "7.0.2",
|
|
51
|
+
"vitest": "4.1.11"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs/promises"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { pathToFileURL } from "node:url"
|
|
6
|
+
|
|
7
|
+
import { runTonnage } from "./run.ts"
|
|
8
|
+
import type { TonnageConfig, TonnageMode } from "./types.ts"
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CONFIG_FILES = [
|
|
11
|
+
`tonnage.config.ts`,
|
|
12
|
+
`tonnage.config.mjs`,
|
|
13
|
+
`tonnage.config.js`,
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
async function main(): Promise<void> {
|
|
17
|
+
const command = process.argv[2]
|
|
18
|
+
const mode = parseMode(command)
|
|
19
|
+
const configPath = await findConfig(process.argv[3])
|
|
20
|
+
const config = await loadConfig(configPath)
|
|
21
|
+
const result = await runTonnage(config, {
|
|
22
|
+
configDirectory: path.dirname(configPath),
|
|
23
|
+
mode,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
if (mode === `check` && result.changed) {
|
|
27
|
+
process.stderr.write(
|
|
28
|
+
`${path.relative(process.cwd(), result.readmePath)} is out of date. Run tonnage write.\n`,
|
|
29
|
+
)
|
|
30
|
+
process.exitCode = 1
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const readme = path.relative(process.cwd(), result.readmePath)
|
|
35
|
+
process.stdout.write(
|
|
36
|
+
result.changed
|
|
37
|
+
? `Updated ${readme}.\n`
|
|
38
|
+
: `${readme} is already up to date.\n`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseMode(command: string | undefined): TonnageMode {
|
|
43
|
+
switch (command) {
|
|
44
|
+
case `check`:
|
|
45
|
+
case `test`:
|
|
46
|
+
return `check`
|
|
47
|
+
case `make`:
|
|
48
|
+
case `write`:
|
|
49
|
+
return `write`
|
|
50
|
+
case undefined:
|
|
51
|
+
default:
|
|
52
|
+
throw new Error(`Usage: tonnage <write|check> [tonnage.config.ts]`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function findConfig(configArgument: string | undefined): Promise<string> {
|
|
57
|
+
if (configArgument) {
|
|
58
|
+
return path.resolve(configArgument)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const filename of DEFAULT_CONFIG_FILES) {
|
|
62
|
+
const candidate = path.resolve(filename)
|
|
63
|
+
try {
|
|
64
|
+
await fs.access(candidate)
|
|
65
|
+
return candidate
|
|
66
|
+
} catch {
|
|
67
|
+
// Keep looking for the next supported config filename.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Could not find a tonnage config (${DEFAULT_CONFIG_FILES.join(`, `)}).`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function loadConfig(configPath: string): Promise<TonnageConfig> {
|
|
77
|
+
const imported = (await import(pathToFileURL(configPath).href)) as {
|
|
78
|
+
default?: unknown
|
|
79
|
+
}
|
|
80
|
+
if (!imported.default || typeof imported.default !== `object`) {
|
|
81
|
+
throw new Error(`${configPath} must default-export a tonnage config.`)
|
|
82
|
+
}
|
|
83
|
+
return imported.default
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
main().catch((error: unknown) => {
|
|
87
|
+
process.stderr.write(
|
|
88
|
+
`${error instanceof Error ? error.message : String(error)}\n`,
|
|
89
|
+
)
|
|
90
|
+
process.exitCode = 1
|
|
91
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { defineConfig } from "./define-config.ts"
|
|
2
|
+
export type { MeasureEntryOptions, MeasureImportsOptions } from "./measure.ts"
|
|
3
|
+
export { measureEntry, measureImports } from "./measure.ts"
|
|
4
|
+
export { tonnageMarkers, updateGeneratedSection } from "./readme.ts"
|
|
5
|
+
export { createTonnageReport, renderTonnageMarkdown } from "./report.ts"
|
|
6
|
+
export { runTonnage } from "./run.ts"
|
|
7
|
+
export type {
|
|
8
|
+
BundleMeasurement,
|
|
9
|
+
BundlePlatform,
|
|
10
|
+
TonnageConfig,
|
|
11
|
+
TonnageExports,
|
|
12
|
+
TonnageMode,
|
|
13
|
+
TonnageRecipe,
|
|
14
|
+
TonnageRecipeRow,
|
|
15
|
+
TonnageReport,
|
|
16
|
+
TonnageRow,
|
|
17
|
+
TonnageRunResult,
|
|
18
|
+
} from "./types.ts"
|
package/src/measure.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { gzipSync } from "node:zlib"
|
|
3
|
+
|
|
4
|
+
import { build, type BuildOptions } from "esbuild"
|
|
5
|
+
|
|
6
|
+
import type { BundleMeasurement, BundlePlatform } from "./types.ts"
|
|
7
|
+
|
|
8
|
+
export type MeasureImportsOptions = {
|
|
9
|
+
external?: readonly string[] | undefined
|
|
10
|
+
platform?: BundlePlatform | undefined
|
|
11
|
+
resolveDirectory: string
|
|
12
|
+
target?: string | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type MeasureEntryOptions = MeasureImportsOptions
|
|
16
|
+
|
|
17
|
+
export async function measureEntry(
|
|
18
|
+
entry: string,
|
|
19
|
+
options: MeasureEntryOptions,
|
|
20
|
+
): Promise<BundleMeasurement> {
|
|
21
|
+
return measureBuild(
|
|
22
|
+
{ entryPoints: [path.resolve(options.resolveDirectory, entry)] },
|
|
23
|
+
options,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function measureImports(
|
|
28
|
+
imports: readonly string[],
|
|
29
|
+
options: MeasureImportsOptions,
|
|
30
|
+
): Promise<BundleMeasurement> {
|
|
31
|
+
if (imports.length === 0) {
|
|
32
|
+
throw new Error(`A tonnage entry must import at least one module.`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const entry = imports
|
|
36
|
+
.map(
|
|
37
|
+
(specifier, index) =>
|
|
38
|
+
`export * as entry${index} from ${JSON.stringify(specifier)}`,
|
|
39
|
+
)
|
|
40
|
+
.join(`\n`)
|
|
41
|
+
return measureBuild(
|
|
42
|
+
{
|
|
43
|
+
stdin: {
|
|
44
|
+
contents: entry,
|
|
45
|
+
loader: `js`,
|
|
46
|
+
resolveDir: options.resolveDirectory,
|
|
47
|
+
sourcefile: `tonnage-entry.js`,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
options,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function measureBuild(
|
|
55
|
+
entry: Pick<BuildOptions, `entryPoints` | `stdin`>,
|
|
56
|
+
options: MeasureEntryOptions,
|
|
57
|
+
): Promise<BundleMeasurement> {
|
|
58
|
+
const platform = options.platform ?? `neutral`
|
|
59
|
+
|
|
60
|
+
const result = await build({
|
|
61
|
+
bundle: true,
|
|
62
|
+
charset: `utf8`,
|
|
63
|
+
external: [
|
|
64
|
+
...(options.external ?? []),
|
|
65
|
+
...(platform === `browser` ? [] : [`node:*`]),
|
|
66
|
+
],
|
|
67
|
+
format: `esm`,
|
|
68
|
+
legalComments: `none`,
|
|
69
|
+
logLevel: `silent`,
|
|
70
|
+
minify: true,
|
|
71
|
+
outdir: `out`,
|
|
72
|
+
platform,
|
|
73
|
+
...entry,
|
|
74
|
+
target: options.target ?? `es2022`,
|
|
75
|
+
treeShaking: true,
|
|
76
|
+
write: false,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const outputFiles = [...(result.outputFiles ?? [])]
|
|
80
|
+
.filter((output) => output.path.endsWith(`.js`))
|
|
81
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
82
|
+
if (outputFiles.length === 0) {
|
|
83
|
+
throw new Error(`The tonnage entry did not emit any runtime JavaScript.`)
|
|
84
|
+
}
|
|
85
|
+
const rawBytes = outputFiles.reduce(
|
|
86
|
+
(total, output) => total + output.contents.byteLength,
|
|
87
|
+
0,
|
|
88
|
+
)
|
|
89
|
+
const gzipBytes = outputFiles.reduce(
|
|
90
|
+
(total, output) =>
|
|
91
|
+
total + gzipSync(output.contents, { level: 9 }).byteLength,
|
|
92
|
+
0,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return { gzipBytes, rawBytes }
|
|
96
|
+
}
|
package/src/readme.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function tonnageMarkers(marker = `default`): {
|
|
2
|
+
end: string
|
|
3
|
+
start: string
|
|
4
|
+
} {
|
|
5
|
+
validateMarker(marker)
|
|
6
|
+
return {
|
|
7
|
+
end: `<!-- tonnage:${marker}:end -->`,
|
|
8
|
+
start: `<!-- tonnage:${marker}:start -->`,
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function updateGeneratedSection(
|
|
13
|
+
readme: string,
|
|
14
|
+
markdown: string,
|
|
15
|
+
marker?: string,
|
|
16
|
+
): string {
|
|
17
|
+
const { end, start } = tonnageMarkers(marker)
|
|
18
|
+
const startIndex = readme.indexOf(start)
|
|
19
|
+
const endIndex = readme.indexOf(end)
|
|
20
|
+
|
|
21
|
+
if (startIndex === -1 || endIndex === -1) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`README must contain exactly one ${start} and one ${end} marker.`,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
if (
|
|
27
|
+
readme.slice(startIndex + start.length).includes(start) ||
|
|
28
|
+
readme.slice(endIndex + end.length).includes(end)
|
|
29
|
+
) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`README must contain exactly one ${start} and one ${end} marker.`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
if (endIndex < startIndex) {
|
|
35
|
+
throw new Error(`${end} must appear after ${start}.`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const contentStart = startIndex + start.length
|
|
39
|
+
return `${readme.slice(0, contentStart)}\n\n${markdown.trim()}\n\n${readme.slice(endIndex)}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateMarker(marker: string): void {
|
|
43
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(marker)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Tonnage marker names may contain letters, numbers, dots, underscores, and hyphens.`,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import fs from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { measureEntry, measureImports } from "./measure.ts"
|
|
5
|
+
import type {
|
|
6
|
+
TonnageConfig,
|
|
7
|
+
TonnageExports,
|
|
8
|
+
TonnageReport,
|
|
9
|
+
TonnageRow,
|
|
10
|
+
} from "./types.ts"
|
|
11
|
+
|
|
12
|
+
type PackageJson = {
|
|
13
|
+
exports?: unknown
|
|
14
|
+
name?: string
|
|
15
|
+
peerDependencies?: Record<string, string>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function createTonnageReport(
|
|
19
|
+
config: TonnageConfig,
|
|
20
|
+
configDirectory: string,
|
|
21
|
+
): Promise<TonnageReport> {
|
|
22
|
+
const packageJsonPath = path.resolve(
|
|
23
|
+
configDirectory,
|
|
24
|
+
config.packageJson ?? `package.json`,
|
|
25
|
+
)
|
|
26
|
+
const packageJson = JSON.parse(
|
|
27
|
+
await fs.readFile(packageJsonPath, `utf8`),
|
|
28
|
+
) as PackageJson
|
|
29
|
+
if (!packageJson.name) {
|
|
30
|
+
throw new Error(`${packageJsonPath} does not declare a package name.`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const resolveDirectory = path.dirname(packageJsonPath)
|
|
34
|
+
const peerDependencies = Object.keys(packageJson.peerDependencies ?? {})
|
|
35
|
+
const external = unique([...peerDependencies, ...(config.external ?? [])])
|
|
36
|
+
const exportImports = selectExportImports(
|
|
37
|
+
packageJson.name,
|
|
38
|
+
packageJson.exports,
|
|
39
|
+
config.exports,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const exportRows = await Promise.all(
|
|
43
|
+
exportImports.map(async (specifier): Promise<TonnageRow> => ({
|
|
44
|
+
...(await measureImports([specifier], {
|
|
45
|
+
external,
|
|
46
|
+
platform: config.platform,
|
|
47
|
+
resolveDirectory,
|
|
48
|
+
target: config.target,
|
|
49
|
+
})),
|
|
50
|
+
imports: [specifier],
|
|
51
|
+
name: specifier,
|
|
52
|
+
})),
|
|
53
|
+
)
|
|
54
|
+
const recipeRows = await Promise.all(
|
|
55
|
+
(config.recipes ?? []).map(async (recipe) => ({
|
|
56
|
+
...(await measureEntry(recipe.entry, {
|
|
57
|
+
external: unique([...external, ...(recipe.external ?? [])]),
|
|
58
|
+
platform: recipe.platform ?? config.platform,
|
|
59
|
+
resolveDirectory,
|
|
60
|
+
target: config.target,
|
|
61
|
+
})),
|
|
62
|
+
entry: recipe.entry,
|
|
63
|
+
name: recipe.name,
|
|
64
|
+
})),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
exports: exportRows,
|
|
69
|
+
packageName: packageJson.name,
|
|
70
|
+
recipes: recipeRows,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function renderTonnageMarkdown(
|
|
75
|
+
report: TonnageReport,
|
|
76
|
+
config: TonnageConfig = {},
|
|
77
|
+
): string {
|
|
78
|
+
const lines = [
|
|
79
|
+
`## ${config.heading ?? `Bundle size`}`,
|
|
80
|
+
``,
|
|
81
|
+
`Public-module rows retain complete runtime export surfaces. Recipe rows bundle their reviewable entry files and tree-shake unused exports. Both report exact minified and level-9 gzip JavaScript byte counts; declarations, source maps, CSS, and other assets are excluded. Peer dependencies stay external, and shared modules are counted once per bundle.`,
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
if (report.exports.length > 0) {
|
|
85
|
+
lines.push(
|
|
86
|
+
``,
|
|
87
|
+
`### Public modules (whole export surface)`,
|
|
88
|
+
``,
|
|
89
|
+
...markdownTable(
|
|
90
|
+
[`Import`, `Minified JS`, `Gzip JS`],
|
|
91
|
+
report.exports.map((row) => [
|
|
92
|
+
code(row.name),
|
|
93
|
+
formatBytes(row.rawBytes),
|
|
94
|
+
formatBytes(row.gzipBytes),
|
|
95
|
+
]),
|
|
96
|
+
[`left`, `right`, `right`],
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (report.recipes.length > 0) {
|
|
102
|
+
lines.push(
|
|
103
|
+
``,
|
|
104
|
+
`### Representative runtimes (tree-shaken)`,
|
|
105
|
+
``,
|
|
106
|
+
...markdownTable(
|
|
107
|
+
[`Recipe`, `Entry`, `Minified JS`, `Gzip JS`],
|
|
108
|
+
report.recipes.map((row) => [
|
|
109
|
+
escapeCell(row.name),
|
|
110
|
+
code(row.entry),
|
|
111
|
+
formatBytes(row.rawBytes),
|
|
112
|
+
formatBytes(row.gzipBytes),
|
|
113
|
+
]),
|
|
114
|
+
[`left`, `left`, `right`, `right`],
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return lines.join(`\n`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function selectExportImports(
|
|
123
|
+
packageName: string,
|
|
124
|
+
exportsValue: unknown,
|
|
125
|
+
selection: TonnageConfig[`exports`],
|
|
126
|
+
): string[] {
|
|
127
|
+
if (selection === false) {
|
|
128
|
+
return []
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const exportKeys = getExportKeys(exportsValue)
|
|
132
|
+
const options: TonnageExports = typeof selection === `object` ? selection : {}
|
|
133
|
+
const included = options.include
|
|
134
|
+
? [...options.include]
|
|
135
|
+
: exportKeys.filter((exportKey) => exportKey !== `./package.json`)
|
|
136
|
+
const excluded = new Set(options.exclude ?? [])
|
|
137
|
+
|
|
138
|
+
return included
|
|
139
|
+
.filter((exportKey) => !excluded.has(exportKey))
|
|
140
|
+
.map((exportKey) => toPackageImport(packageName, exportKey))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getExportKeys(exportsValue: unknown): string[] {
|
|
144
|
+
if (typeof exportsValue === `string` || Array.isArray(exportsValue)) {
|
|
145
|
+
return [`.`]
|
|
146
|
+
}
|
|
147
|
+
if (!exportsValue || typeof exportsValue !== `object`) {
|
|
148
|
+
return [`.`]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const keys = Object.keys(exportsValue)
|
|
152
|
+
return keys.some((key) => key.startsWith(`.`)) ? keys : [`.`]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function toPackageImport(packageName: string, exportKey: string): string {
|
|
156
|
+
if (exportKey === `.`) {
|
|
157
|
+
return packageName
|
|
158
|
+
}
|
|
159
|
+
if (!exportKey.startsWith(`./`)) {
|
|
160
|
+
throw new Error(`Invalid package export key: ${exportKey}`)
|
|
161
|
+
}
|
|
162
|
+
return `${packageName}/${exportKey.slice(2)}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function formatBytes(bytes: number): string {
|
|
166
|
+
return `${new Intl.NumberFormat(`en-US`).format(bytes)} B`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function code(value: string): string {
|
|
170
|
+
return `<code>${escapeCell(value)}</code>`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function escapeCell(value: string): string {
|
|
174
|
+
return value.replaceAll(`&`, `&`).replaceAll(`|`, `|`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function unique(values: readonly string[]): string[] {
|
|
178
|
+
return [...new Set(values)]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function markdownTable(
|
|
182
|
+
headings: readonly string[],
|
|
183
|
+
rows: readonly (readonly string[])[],
|
|
184
|
+
alignments: readonly (`left` | `right`)[],
|
|
185
|
+
): string[] {
|
|
186
|
+
const widths = headings.map((heading, column) =>
|
|
187
|
+
Math.max(heading.length, ...rows.map((row) => row[column]?.length ?? 0), 3),
|
|
188
|
+
)
|
|
189
|
+
const renderRow = (cells: readonly string[]): string =>
|
|
190
|
+
`| ${cells.map((cell, column) => padCell(cell, widths[column] ?? 3, alignments[column] ?? `left`)).join(` | `)} |`
|
|
191
|
+
const separator = widths.map((width, column) =>
|
|
192
|
+
alignments[column] === `right`
|
|
193
|
+
? `${`-`.repeat(width - 1)}:`
|
|
194
|
+
: `-`.repeat(width),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return [renderRow(headings), renderRow(separator), ...rows.map(renderRow)]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function padCell(
|
|
201
|
+
cell: string,
|
|
202
|
+
width: number,
|
|
203
|
+
alignment: `left` | `right`,
|
|
204
|
+
): string {
|
|
205
|
+
return alignment === `right` ? cell.padStart(width) : cell.padEnd(width)
|
|
206
|
+
}
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { updateGeneratedSection } from "./readme.ts"
|
|
5
|
+
import { createTonnageReport, renderTonnageMarkdown } from "./report.ts"
|
|
6
|
+
import type { TonnageConfig, TonnageMode, TonnageRunResult } from "./types.ts"
|
|
7
|
+
|
|
8
|
+
export async function runTonnage(
|
|
9
|
+
config: TonnageConfig,
|
|
10
|
+
options: {
|
|
11
|
+
configDirectory?: string
|
|
12
|
+
mode: TonnageMode
|
|
13
|
+
},
|
|
14
|
+
): Promise<TonnageRunResult> {
|
|
15
|
+
const configDirectory = path.resolve(options.configDirectory ?? process.cwd())
|
|
16
|
+
const readmePath = path.resolve(configDirectory, config.readme ?? `README.md`)
|
|
17
|
+
const oldReadme = await fs.readFile(readmePath, `utf8`)
|
|
18
|
+
const report = await createTonnageReport(config, configDirectory)
|
|
19
|
+
const markdown = renderTonnageMarkdown(report, config)
|
|
20
|
+
const newReadme = updateGeneratedSection(oldReadme, markdown, config.marker)
|
|
21
|
+
const changed = oldReadme !== newReadme
|
|
22
|
+
|
|
23
|
+
if (options.mode === `write` && changed) {
|
|
24
|
+
await fs.writeFile(readmePath, newReadme)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { changed, readmePath, report }
|
|
28
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type BundlePlatform = `browser` | `neutral` | `node`
|
|
2
|
+
|
|
3
|
+
export type TonnageExports = {
|
|
4
|
+
exclude?: readonly string[]
|
|
5
|
+
include?: readonly string[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type TonnageRecipe = {
|
|
9
|
+
entry: string
|
|
10
|
+
external?: readonly string[]
|
|
11
|
+
name: string
|
|
12
|
+
platform?: BundlePlatform
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TonnageConfig = {
|
|
16
|
+
exports?: boolean | TonnageExports
|
|
17
|
+
external?: readonly string[]
|
|
18
|
+
heading?: string
|
|
19
|
+
marker?: string
|
|
20
|
+
packageJson?: string
|
|
21
|
+
platform?: BundlePlatform
|
|
22
|
+
readme?: string
|
|
23
|
+
recipes?: readonly TonnageRecipe[]
|
|
24
|
+
target?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type BundleMeasurement = {
|
|
28
|
+
gzipBytes: number
|
|
29
|
+
rawBytes: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type TonnageRow = BundleMeasurement & {
|
|
33
|
+
imports: readonly string[]
|
|
34
|
+
name: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type TonnageRecipeRow = BundleMeasurement & {
|
|
38
|
+
entry: string
|
|
39
|
+
name: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type TonnageReport = {
|
|
43
|
+
exports: readonly TonnageRow[]
|
|
44
|
+
packageName: string
|
|
45
|
+
recipes: readonly TonnageRecipeRow[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type TonnageMode = `check` | `write`
|
|
49
|
+
|
|
50
|
+
export type TonnageRunResult = {
|
|
51
|
+
changed: boolean
|
|
52
|
+
readmePath: string
|
|
53
|
+
report: TonnageReport
|
|
54
|
+
}
|