Skip to content

Commit 34426f2

Browse files
authored
page stats (#1291)
* log page stats * tree output * localized size format * fancier layout * magenta if ≥10 MB * tidier code
1 parent 75bc5c5 commit 34426f2

File tree

9 files changed

+273
-37
lines changed

9 files changed

+273
-37
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"cross-env": "^7.0.3",
6767
"cross-spawn": "^7.0.3",
6868
"d3-array": "^3.2.4",
69+
"d3-hierarchy": "^3.1.2",
6970
"esbuild": "^0.20.1",
7071
"fast-array-diff": "^1.1.0",
7172
"gray-matter": "^4.0.3",

src/build.ts

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {createHash} from "node:crypto";
22
import {existsSync} from "node:fs";
3-
import {access, constants, copyFile, readFile, writeFile} from "node:fs/promises";
3+
import {access, constants, copyFile, readFile, stat, writeFile} from "node:fs/promises";
44
import {basename, dirname, extname, join} from "node:path/posix";
55
import type {Config} from "./config.js";
66
import {CliError, isEnoent} from "./error.js";
@@ -12,15 +12,16 @@ import type {MarkdownPage} from "./markdown.js";
1212
import {parseMarkdown} from "./markdown.js";
1313
import {extractNodeSpecifier} from "./node.js";
1414
import {extractNpmSpecifier, populateNpmCache, resolveNpmImport} from "./npm.js";
15-
import {isPathImport, relativePath, resolvePath} from "./path.js";
15+
import {isAssetPath, isPathImport, relativePath, resolvePath} from "./path.js";
1616
import {renderPage} from "./render.js";
1717
import type {Resolvers} from "./resolvers.js";
1818
import {getModuleResolver, getResolvers} from "./resolvers.js";
1919
import {resolveImportPath, resolveStylesheetPath} from "./resolvers.js";
2020
import {bundleStyles, rollupClient} from "./rollup.js";
2121
import {searchIndex} from "./search.js";
2222
import {Telemetry} from "./telemetry.js";
23-
import {faint, yellow} from "./tty.js";
23+
import {tree} from "./tree.js";
24+
import {faint, green, magenta, red, yellow} from "./tty.js";
2425

2526
export interface BuildOptions {
2627
config: Config;
@@ -218,15 +219,12 @@ export async function build(
218219
await effects.writeFile(alias, contents);
219220
}
220221

221-
// Render pages, resolving against content-hashed file names!
222-
for (const [sourceFile, {page, resolvers}] of pages) {
223-
const sourcePath = join(root, sourceFile);
224-
const outputPath = join(dirname(sourceFile), basename(sourceFile, ".md") + ".html");
222+
// Wrap the resolvers to apply content-hashed file names.
223+
for (const [sourceFile, page] of pages) {
225224
const path = join("/", dirname(sourceFile), basename(sourceFile, ".md"));
226-
const options = {path, ...config};
227-
effects.output.write(`${faint("render")} ${sourcePath} ${faint("→")} `);
228-
const html = await renderPage(page, {
229-
...options,
225+
const {resolvers} = page;
226+
pages.set(sourceFile, {
227+
...page,
230228
resolvers: {
231229
...resolvers,
232230
resolveFile(specifier) {
@@ -251,12 +249,86 @@ export async function build(
251249
}
252250
}
253251
});
252+
}
253+
254+
// Render pages!
255+
for (const [sourceFile, {page, resolvers}] of pages) {
256+
const sourcePath = join(root, sourceFile);
257+
const outputPath = join(dirname(sourceFile), basename(sourceFile, ".md") + ".html");
258+
const path = join("/", dirname(sourceFile), basename(sourceFile, ".md"));
259+
effects.output.write(`${faint("render")} ${sourcePath} ${faint("→")} `);
260+
const html = await renderPage(page, {...config, path, resolvers});
254261
await effects.writeFile(outputPath, html);
255262
}
256263

264+
// Log page sizes.
265+
const columnWidth = 12;
266+
effects.logger.log("");
267+
for (const [indent, name, description, node] of tree(pages)) {
268+
if (node.children) {
269+
effects.logger.log(
270+
`${faint(indent)}${name}${faint(description)} ${
271+
node.depth ? "" : ["Page", "Imports", "Files"].map((name) => name.padStart(columnWidth)).join(" ")
272+
}`
273+
);
274+
} else {
275+
const [sourceFile, {resolvers}] = node.data!;
276+
const outputPath = join(dirname(sourceFile), basename(sourceFile, ".md") + ".html");
277+
const path = join("/", dirname(sourceFile), basename(sourceFile, ".md"));
278+
const resolveOutput = (name: string) => join(config.output, resolvePath(path, name));
279+
const pageSize = (await stat(join(config.output, outputPath))).size;
280+
const importSize = await accumulateSize(resolvers.staticImports, resolvers.resolveImport, resolveOutput);
281+
const fileSize =
282+
(await accumulateSize(resolvers.files, resolvers.resolveFile, resolveOutput)) +
283+
(await accumulateSize(resolvers.assets, resolvers.resolveFile, resolveOutput)) +
284+
(await accumulateSize(resolvers.stylesheets, resolvers.resolveStylesheet, resolveOutput));
285+
effects.logger.log(
286+
`${faint(indent)}${name}${description} ${[pageSize, importSize, fileSize]
287+
.map((size) => formatBytes(size, columnWidth))
288+
.join(" ")}`
289+
);
290+
}
291+
}
292+
effects.logger.log("");
293+
257294
Telemetry.record({event: "build", step: "finish", pageCount});
258295
}
259296

297+
async function accumulateSize(
298+
files: Iterable<string>,
299+
resolveFile: (path: string) => string,
300+
resolveOutput: (path: string) => string
301+
): Promise<number> {
302+
let size = 0;
303+
for (const file of files) {
304+
const fileResolution = resolveFile(file);
305+
if (isAssetPath(fileResolution)) {
306+
try {
307+
size += (await stat(resolveOutput(fileResolution))).size;
308+
} catch {
309+
// ignore missing file
310+
}
311+
}
312+
}
313+
return size;
314+
}
315+
316+
function formatBytes(size: number, length: number, locale: Intl.LocalesArgument = "en-US"): string {
317+
let color: (text: string) => string;
318+
let text: string;
319+
if (size < 1e3) {
320+
text = "<1 kB";
321+
color = faint;
322+
} else if (size < 1e6) {
323+
text = (size / 1e3).toLocaleString(locale, {maximumFractionDigits: 0}) + " kB";
324+
color = green;
325+
} else {
326+
text = (size / 1e6).toLocaleString(locale, {minimumFractionDigits: 3, maximumFractionDigits: 3}) + " MB";
327+
color = size < 10e6 ? yellow : size < 50e6 ? magenta : red;
328+
}
329+
return color(text.padStart(length));
330+
}
331+
260332
export class FileBuildEffects implements BuildEffects {
261333
private readonly outputRoot: string;
262334
readonly logger: Logger;

src/dataloader.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import JSZip from "jszip";
99
import {extract} from "tar-stream";
1010
import {maybeStat, prepareOutput} from "./files.js";
1111
import {FileWatchers} from "./fileWatchers.js";
12+
import {formatByteSize} from "./format.js";
1213
import {getFileInfo} from "./javascript/module.js";
1314
import type {Logger, Writer} from "./logger.js";
1415
import {cyan, faint, green, red, yellow} from "./tty.js";
@@ -267,8 +268,9 @@ export abstract class Loader {
267268
const start = performance.now();
268269
command.then(
269270
(path) => {
271+
const {size} = statSync(join(this.root, path));
270272
effects.logger.log(
271-
`${green("success")} ${cyan(formatSize(statSync(join(this.root, path)).size))} ${faint(
273+
`${green("success")} ${size ? cyan(formatByteSize(size)) : yellow("empty output")} ${faint(
272274
`in ${formatElapsed(start)}`
273275
)}`
274276
);
@@ -395,12 +397,6 @@ const extractors = [
395397
[".tgz", TarGzExtractor]
396398
] as const;
397399

398-
function formatSize(size: number): string {
399-
if (!size) return yellow("empty output");
400-
const e = Math.floor(Math.log(size) / Math.log(1024));
401-
return `${+(size / 1024 ** e).toFixed(2)} ${["bytes", "KiB", "MiB", "GiB", "TiB"][e]}`;
402-
}
403-
404400
function formatElapsed(start: number): string {
405401
const elapsed = performance.now() - start;
406402
return `${Math.floor(elapsed)}ms`;

src/format.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@ function pad(number: number, length: number): string {
1414
return String(number).padStart(length, "0");
1515
}
1616

17-
export function formatByteSize(n: number): string {
18-
const suffixes = ["bytes", "KB", "MB", "GB"];
19-
for (const suffix of suffixes) {
20-
if (n < 1300) {
21-
if (suffix === "bytes") return `${n} ${suffix}`;
22-
return `${n > 100 ? n.toFixed(0) : n.toFixed(1)} ${suffix}`;
17+
export function formatByteSize(x: number, locale: Intl.LocalesArgument = "en-US"): string {
18+
const formatOptions = {maximumSignificantDigits: 3, maximumFractionDigits: 2};
19+
for (const [k, suffix] of [
20+
[1e9, " GB"],
21+
[1e6, " MB"],
22+
[1e3, " kB"]
23+
] as const) {
24+
if (Math.round((x / k) * 1e3) >= 1e3) {
25+
return (x / k).toLocaleString(locale, formatOptions) + suffix;
2326
}
24-
n /= 1000;
2527
}
26-
return `${n.toFixed(1)} TB`;
28+
return x.toLocaleString(locale, formatOptions) + " B";
2729
}

src/tree.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {ascending, max} from "d3-array";
2+
import {stratify} from "d3-hierarchy";
3+
4+
export type TreeItem<T> = [path: string, value: T];
5+
6+
export interface TreeNode<T> {
7+
data?: TreeItem<T>;
8+
parent?: TreeNode<T>;
9+
children?: TreeNode<T>[];
10+
value: number;
11+
height: number;
12+
depth: number;
13+
id: string;
14+
}
15+
16+
export function tree<T>(
17+
items: Iterable<TreeItem<T>>
18+
): [indent: string, name: string, description: string, node: TreeNode<T>][] {
19+
const lines: [indent: string, name: string, description: string, node: TreeNode<T>][] = [];
20+
stratify()
21+
.path(
22+
([path]) =>
23+
path.replace(/\.md$/, "") + // remove .md extension
24+
(path.endsWith("/") ? "" : "?") // distinguish files from folders
25+
)([...items, ["/"]]) // add root to avoid implicit truncation
26+
.sort(treeOrder)
27+
.count()
28+
.eachBefore((node: TreeNode<T>) => {
29+
let p = node;
30+
let indent = "";
31+
if (p.parent) {
32+
indent = (hasFollowingSibling(p) ? "├── " : "└── ") + indent;
33+
while ((p = p.parent!).parent) {
34+
indent = (hasFollowingSibling(p) ? "│   " : "    ") + indent;
35+
}
36+
}
37+
lines.push([
38+
indent || "┌",
39+
`${node.id.split("/").pop()?.replace(/\?$/, "")}`,
40+
node.height ? ` (${node.value.toLocaleString("en-US")} page${node.value === 1 ? "" : "s"})` : "",
41+
node
42+
]);
43+
});
44+
const width =
45+
(max(lines, ([indent, name, description]) => indent.length + description.length + stringLength(name)) || 0) + 1;
46+
return lines.map(([indent, name, description, node]) => [
47+
indent,
48+
name,
49+
description + " ".repeat(width - stringLength(name) - description.length - indent.length),
50+
node
51+
]);
52+
}
53+
54+
/** Counts the number of graphemes in the specified string. */
55+
function stringLength(string: string): number {
56+
return [...new Intl.Segmenter().segment(string)].length;
57+
}
58+
59+
function hasFollowingSibling(node: TreeNode<unknown>): boolean {
60+
return node.parent != null && node.parent.children!.indexOf(node) < node.parent.children!.length - 1;
61+
}
62+
63+
function treeOrder(a: TreeNode<unknown>, b: TreeNode<unknown>): number {
64+
return ascending(!a.children, !b.children) || ascending(a.id, b.id);
65+
}

test/format-test.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,22 @@ describe("formatLocaleDate", () => {
2424

2525
describe("formatByteSize", () => {
2626
it("returns a human-readable byte size", () => {
27-
assert.strictEqual(formatByteSize(0), "0 bytes");
28-
assert.strictEqual(formatByteSize(500), "500 bytes");
29-
assert.strictEqual(formatByteSize(1200), "1200 bytes");
30-
assert.strictEqual(formatByteSize(1_500), "1.5 KB");
31-
assert.strictEqual(formatByteSize(3_000), "3.0 KB");
32-
assert.strictEqual(formatByteSize(400_000), "400 KB");
33-
assert.strictEqual(formatByteSize(50_000_000), "50.0 MB");
34-
assert.strictEqual(formatByteSize(1_250_000_000), "1250 MB");
27+
assert.strictEqual(formatByteSize(0), "0 B");
28+
assert.strictEqual(formatByteSize(500), "500 B");
29+
assert.strictEqual(formatByteSize(999), "999 B");
30+
assert.strictEqual(formatByteSize(999.49), "999 B");
31+
assert.strictEqual(formatByteSize(999.51), "1 kB");
32+
assert.strictEqual(formatByteSize(1200), "1.2 kB");
33+
assert.strictEqual(formatByteSize(1_500), "1.5 kB");
34+
assert.strictEqual(formatByteSize(3_000), "3 kB");
35+
assert.strictEqual(formatByteSize(400_000), "400 kB");
36+
assert.strictEqual(formatByteSize(50_000_000), "50 MB");
37+
assert.strictEqual(formatByteSize(1_250_000_000), "1.25 GB");
3538
assert.strictEqual(formatByteSize(1_500_000_000), "1.5 GB");
36-
assert.strictEqual(formatByteSize(3_000_000_000), "3.0 GB");
39+
assert.strictEqual(formatByteSize(3_000_000_000), "3 GB");
3740
assert.strictEqual(formatByteSize(600_000_000_000), "600 GB");
3841
assert.strictEqual(formatByteSize(60_598_160), "60.6 MB");
3942
assert.strictEqual(formatByteSize(60_598_160_000), "60.6 GB");
40-
assert.strictEqual(formatByteSize(60_598_160_000_000), "60.6 TB");
43+
assert.strictEqual(formatByteSize(60_598_160_000_000), "60,600 GB");
4144
});
4245
});

test/markdown-test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {isEnoent} from "../src/error.js";
88
import type {MarkdownPage} from "../src/markdown.js";
99
import {makeLinkNormalizer, parseMarkdown} from "../src/markdown.js";
1010

11-
const {md} = normalizeConfig();
11+
const {md} = normalizeConfig({root: "docs"});
1212

1313
describe("parseMarkdown(input)", () => {
1414
const inputRoot = "test/input";

0 commit comments

Comments
 (0)