1/**
2 * Bundle report generation for the Effect bundle-size tooling.
3 *
4 * The reporter coordinates fixture discovery with the Rollup service to turn
5 * measured fixture bundles into Markdown tables or visualization output. It is
6 * used by the bundle CLI to compare the current workspace against a checked-out
7 * base directory, to print a one-off report for selected entry files, and to
8 * generate visualizations when a size change needs inspection.
9 *
10 * Reports compare files by basename and display gzipped Rollup output sizes in
11 * decimal kilobytes. Base fixtures are bundled only when the matching file
12 * exists; if a current fixture has no matching basename in the base directory it
13 * is reported as unchanged. Visualization artifacts are named from entry file
14 * stems in the requested output directory, so duplicate names can make the
15 * output misleading.
16 *
17 * @since 4.0.0
18 */
19import * as Context from "effect/Context"
20import * as Data from "effect/Data"
21import * as Effect from "effect/Effect"
22import * as FileSystem from "effect/FileSystem"
23import { constFalse } from "effect/Function"
24import * as Layer from "effect/Layer"
25import * as Path from "effect/Path"
26import { fileURLToPath } from "node:url"
27import { Fixtures } from "./Fixtures.ts"
28import type { BundleStats } from "./Rollup.ts"
29import { Rollup } from "./Rollup.ts"
30
31/**
32 * Error raised when generating a bundle size report or visualization fails.
33 *
34 * @category errors
35 * @since 4.0.0
36 */
37export class ReporterError extends Data.TaggedError("ReporterError")<{
38 readonly cause: unknown
39}> {}
40
41/**
42 * Options for generating a bundle size comparison report against fixture files from a base directory.
43 *
44 * @category options
45 * @since 4.0.0
46 */
47export interface ReportOptions {
48 readonly baseDirectory: string
49}
50
51/**
52 * Options for generating bundle visualizations for selected entry files into an output directory.
53 *
54 * @category options
55 * @since 4.0.0
56 */
57export interface VisualizeOptions {
58 readonly paths: ReadonlyArray<string>
59 readonly outputDirectory: string
60}
61
62/**
63 * Options for generating a bundle size report for an explicit list of entry files.
64 *
65 * @category options
66 * @since 4.0.0
67 */
68export interface ReportSelectedOptions {
69 readonly paths: ReadonlyArray<string>
70}
71
72/**
73 * Options for generating a bundle size comparison report for explicit entry files against a base checkout.
74 *
75 * @category options
76 * @since 4.0.0
77 */
78export interface ReportSelectedComparisonOptions {
79 readonly baseDirectory: string
80 readonly paths: ReadonlyArray<string>
81}
82
83/**
84 * Context service for producing bundle size reports and visualizations from Rollup-generated fixture stats.
85 *
86 * @category services
87 * @since 4.0.0
88 */
89export class Reporter extends Context.Service<Reporter>()(
90 "@effect/bundle/Reporter",
91 {
92 make: Effect.gen(function*() {
93 const fs = yield* FileSystem.FileSystem
94 const path = yield* Path.Path
95 const { fixtures, fixturesDir } = yield* Fixtures
96 const rollup = yield* Rollup
97 const currentDirectory = path.resolve(fileURLToPath(new URL("../../../../", import.meta.url)))
98
99 const calculateDifference = (current: BundleStats, previous: BundleStats) => {
100 const currSize = current.sizeInBytes
101 const prevSize = previous.sizeInBytes
102 const diff = currSize - prevSize
103 const diffPct = prevSize === 0 ? 0 : (Math.abs(diff) / prevSize) * 100
104 const currKb = (currSize / 1000).toFixed(2)
105 const prevKb = (prevSize / 1000).toFixed(2)
106 const diffKb = (Math.abs(diff) / 1000).toFixed(2)
107 const filename = path.basename(current.path)
108 return {
109 diff,
110 diffPct,
111 currKb,
112 prevKb,
113 diffKb,
114 filename
115 }
116 }
117
118 const createComparisonReport = (
119 entries: ReadonlyArray<{
120 readonly current: BundleStats
121 readonly previous: BundleStats
122 readonly filename: string
123 }>
124 ): string => {
125 const lines: Array<string> = [
126 "| File Name | Current Size | Previous Size | Difference |",
127 "|:----------|:------------:|:-------------:|:----------:|"
128 ]
129 for (const { current, previous, filename } of entries) {
130 const comparison = calculateDifference(current, previous)
131 const currKb = `${comparison.currKb} KB`
132 const prevKb = `${comparison.prevKb} KB`
133 const diffKb = `${comparison.diffKb} KB`
134 const diffPct = `${comparison.diffPct.toFixed(2)}%`
135 const sign = comparison.diff === 0 ? "" : comparison.diff > 0 ? "+" : "-"
136 const line = `| \`${filename}\` | ${currKb} | ${prevKb} | ${sign}${diffKb} (${sign}${diffPct}) |`
137 lines.push(line)
138 }
139 return lines.join("\n") + "\n"
140 }
141
142 const createReport = (curr: ReadonlyArray<BundleStats>, prev: ReadonlyArray<BundleStats>): string => {
143 const entries: Array<{
144 readonly current: BundleStats
145 readonly previous: BundleStats
146 readonly filename: string
147 }> = []
148 for (const current of curr) {
149 const previous = prev.find((previous) => {
150 return path.basename(previous.path) === path.basename(current.path)
151 }) ?? current
152 entries.push({
153 current,
154 previous,
155 filename: path.basename(current.path)
156 })
157 }
158 return createComparisonReport(entries)
159 }
160
161 const createSelectedReport = (stats: ReadonlyArray<BundleStats>): string => {
162 const lines: Array<string> = [
163 "| File Name | Current Size |",
164 "|:----------|:------------:|"
165 ]
166
167 for (const current of stats) {
168 const filename = `\`${path.basename(current.path)}\``
169 const currKb = `${(current.sizeInBytes / 1000).toFixed(2)} KB`
170 const line = `| ${filename} | ${currKb} |`
171 lines.push(line)
172 }
173
174 return lines.join("\n") + "\n"
175 }
176
177 const createVisualizationReport = (paths: ReadonlyArray<string>, outputDirectory: string): string => {
178 const lines: Array<string> = [
179 "| File Name | Generated Bundle | Treemap | Raw Data |",
180 "|:----------|:----------------|:--------|:---------|"
181 ]
182 for (const entryPath of paths) {
183 const name = path.parse(entryPath).name
184 const filename = path.relative(currentDirectory, path.resolve(entryPath))
185 const minified = path.join(outputDirectory, `${name}.min.js`)
186 const treemap = path.join(outputDirectory, `${name}.treemap.html`)
187 const rawData = path.join(outputDirectory, `${name}.raw-data.json`)
188 lines.push(`| \`${filename}\` | \`${minified}\` | \`${treemap}\` | \`${rawData}\` |`)
189 }
190 return lines.join("\n") + "\n"
191 }
192
193 const report = Effect.fn("Reporter.report")(
194 function*(options: ReportOptions) {
195 yield* Effect.logInfo(`Found ${fixtures.length} files to bundle`)
196
197 const currentPaths = fixtures.map((fixture) => path.join(fixturesDir, fixture))
198 const previousPaths = yield* Effect.filter(
199 fixtures.map((fixture) => path.join(options.baseDirectory, fixture)),
200 (previousPath) => fs.exists(previousPath).pipe(Effect.orElseSucceed(constFalse)),
201 { concurrency: fixtures.length }
202 )
203
204 const currentStats = yield* rollup.bundleAll({ paths: currentPaths })
205 const previousStats = yield* rollup.bundleAll({ paths: previousPaths })
206
207 yield* Effect.logInfo("Bundling complete! Generating bundle size report...")
208
209 return createReport(currentStats, previousStats)
210 }
211 )
212
213 const visualize = Effect.fn("Reporter.visualize")(
214 function*(options: VisualizeOptions) {
215 yield* fs.makeDirectory(options.outputDirectory, { recursive: true })
216 yield* rollup.bundleAll({
217 paths: options.paths,
218 outputDirectory: options.outputDirectory,
219 visualize: true
220 })
221 return createVisualizationReport(options.paths, options.outputDirectory)
222 }
223 )
224
225 const reportSelected = Effect.fn("Reporter.reportSelected")(
226 function*(options: ReportSelectedOptions) {
227 yield* Effect.logInfo(`Found ${options.paths.length} files to bundle`)
228 const stats = yield* rollup.bundleAll({ paths: options.paths })
229 yield* Effect.logInfo("Bundling complete! Generating bundle size report...")
230 return createSelectedReport(stats)
231 }
232 )
233
234 const reportSelectedComparison = Effect.fn("Reporter.reportSelectedComparison")(
235 function*(options: ReportSelectedComparisonOptions) {
236 yield* Effect.logInfo(`Found ${options.paths.length} files to compare`)
237 const baseDirectory = path.resolve(options.baseDirectory)
238 const currentPaths = options.paths.map((currentPath) => path.resolve(currentPath))
239 const previousPaths = yield* Effect.forEach(
240 currentPaths,
241 Effect.fnUntraced(function*(currentPath) {
242 const relativePath = path.relative(currentDirectory, currentPath)
243 if (relativePath === "" || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
244 return yield* Effect.fail(
245 new ReporterError({
246 cause: `Selected bundle entry must be inside ${currentDirectory}: ${currentPath}`
247 })
248 )
249 }
250 const previousPath = path.join(baseDirectory, relativePath)
251 yield* fs.makeDirectory(path.dirname(previousPath), { recursive: true })
252 yield* fs.copy(currentPath, previousPath, { overwrite: true })
253 return previousPath
254 }),
255 { concurrency: currentPaths.length }
256 )
257
258 const currentStats = yield* rollup.bundleAll({ paths: currentPaths })
259 const previousStats = yield* rollup.bundleAll({ paths: previousPaths })
260
261 const entries: Array<{
262 readonly current: BundleStats
263 readonly previous: BundleStats
264 readonly filename: string
265 }> = []
266 for (let i = 0; i < currentStats.length; i++) {
267 entries.push({
268 current: currentStats[i]!,
269 previous: previousStats[i]!,
270 filename: path.relative(currentDirectory, currentStats[i]!.path)
271 })
272 }
273 yield* Effect.logInfo("Bundling complete! Generating bundle size report...")
274 return createComparisonReport(entries)
275 }
276 )
277
278 return {
279 report,
280 reportSelectedComparison,
281 reportSelected,
282 visualize
283 } as const
284 })
285 }
286) {
287 static readonly layer = Layer.effect(this, this.make).pipe(
288 Layer.provide(Fixtures.layer),
289 Layer.provide(Rollup.layer)
290 )
291}