~james/core

core/web/src/lib/api/blob.ts -rw-r--r-- 4.1 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import type { Transporter } from "@sveltejs/kit/hooks";
import type { CommitSummary } from "#lib/api/repo.ts";
import { isMarkdownFile } from "#lib/markup/index.ts";
import { fitsServerRender } from "#lib/ssr/render.ts";

export type BlobKind = "code" | "markup" | "svg" | "image" | "video" | "submodule" | "other";

export type BlobViewMode = "code" | "rendered";

export interface RepoBlobView {
	ref: string;
	path: string;
	kind: BlobKind;
	sizeLabel: string | null;
	lines: number | null;
	contents: string | null;
	fileTooLarge: boolean;
	defaultView: BlobViewMode;
	submodule: { name: string; url: string } | null;
	lastCommit: CommitSummary | null;
	// only set when contents is null. the browser checks its read-back against it
	contentsCheck?: string;
}

// not crypto, a collision only buys the text that is already on screen
export const textCheck = (text: string): string => {
	let h1 = 0xdeadbeef;
	let h2 = 0x41c6ce57;
	for (let i = 0; i < text.length; i++) {
		const c = text.charCodeAt(i);
		h1 = Math.imul(h1 ^ c, 2654435761);
		h2 = Math.imul(h2 ^ c, 1597334677);
	}
	h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
	h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
	return `${text.length}:${(4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36)}`;
};

const inPage = new WeakSet<RepoBlobView>();

// pierre's markup mangles \r and NUL, and text over the server render limit isn't in the page
export const renderedInPage = (blob: RepoBlobView): RepoBlobView => {
	if (
		blob.defaultView === "code" &&
		blob.contents !== null &&
		fitsServerRender(blob.contents.length) &&
		!/[\r\0]/.test(blob.contents)
	)
		inPage.add(blob);
	return blob;
};

export const blobTransport: Transporter<RepoBlobView, [RepoBlobView]> = {
	encode: (blob) =>
		inPage.has(blob) &&
		blob.contents !== null && [
			{ ...blob, contents: null, contentsCheck: textCheck(blob.contents) }
		],
	decode: ([blob]) => blob
};

// same 1MiB inline cap as the appview's maxBlobSize
export const MAX_INLINE_SIZE = 1 << 20;

// go-humanize's Bytes
export const formatBytes = (bytes: number): string => {
	if (bytes < 1000) return `${bytes} B`;
	const units = ["kB", "MB", "GB", "TB", "PB", "EB"];
	let value = bytes;
	let unit = "B";
	for (const next of units) {
		if (value < 1000) break;
		value /= 1000;
		unit = next;
	}
	let rendered = value < 10 ? value.toFixed(1) : String(Math.round(value));
	// rounding can spill over the boundary ("1000 kB"), promote to the
	// next unit and render again ("1.0 MB")
	if (Number.parseFloat(rendered) >= 1000 && unit !== "EB") {
		value /= 1000;
		unit = units[units.indexOf(unit) + 1];
		rendered = value < 10 ? value.toFixed(1) : String(Math.round(value));
	}
	return `${rendered} ${unit}`;
};

// textual application/* types (json, toml, ...) arrive with isBinary=false
// instead of a text/* mime
export const classifyBlob = (blob: {
	path: string;
	mimeType?: string;
	isBinary?: boolean;
	submodule?: unknown;
}): BlobKind => {
	if (blob.submodule) return "submodule";
	const mediaType = (blob.mimeType ?? "").split(";")[0].trim().toLowerCase();
	if (mediaType === "image/svg+xml") return "svg";
	if (mediaType.startsWith("image/")) return "image";
	if (mediaType.startsWith("video/")) return "video";
	if (blob.isBinary === false || mediaType.startsWith("text/")) {
		return isMarkdownFile(blob.path) ? "markup" : "code";
	}
	return "other";
};

export const hasTextView = (kind: BlobKind): boolean =>
	kind === "code" || kind === "markup" || kind === "svg";

export const hasRenderedView = (kind: BlobKind): boolean => kind === "markup" || kind === "svg";

// a trailing newline terminates the last line instead of starting a new one
export const countLines = (text: string): number => {
	if (text === "") return 0;
	const lines = text.split("\n").length;
	return text.endsWith("\n") ? lines - 1 : lines;
};

const textEncoder = new TextEncoder();

export const contentStats = (
	contents: string
): { lines: number; bytes: number; sizeLabel: string } => {
	const bytes = textEncoder.encode(contents).length;
	return { lines: countLines(contents), bytes, sizeLabel: formatBytes(bytes) };
};