authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-09-28 02:52:02-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:47-07:00
logcd988f1930b18b9a8a7f443f104b45569efb5f6d
treeb794b5cf78b3222a6b681087c3e3f914387006e8
parent17cea8e03404d3146f5a90c020abae96f4e142fe
signature Commit is signed but in an unrecognized format.

feat(lib/log): custom stack trace printer

the default stack trace formatters in node.js, deno, and bun are all extremely low quality. they don't highlight the most important information, and spend too much space on showing source code. this printer puts the error metadata at the top. each stack frame gets two lines: the absolute path or url, and then one line of source code. having five lines of context on just the top frame is useless, since five lines is often not enough context to understand the entire block of code. one line is all you need to remind yourself what the code on that line was doing. and if you can see every frame's source code, you can much more easily figure out what the bug is before opening the editor. to make understanding runtime code easier, `node:*` and `ext:deno` paths are resolved into GitHub source code URLs, and node builtins will also display their source code inline (via `process.binding("natives")`). i'm unaware of any api on bun or deno to do the correct remapping, but best effort is done to point to something that can actually be read. with zero required imports, it can be safely included as a part of the headless log and widget host without worrying about if the module can import successfully. because of this, i'm enabling this printer by default everywhere.

7 files changed, 436 insertions(+), 10 deletions(-)

lib/deno.lock created+17
...@@ -0,0 +1,17 @@
1{
2 "version": "5",
3 "specifiers": {
4 "npm:@types/node@*": "24.2.0"
5 },
6 "npm": {
7 "@types/node@24.2.0": {
8 "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
9 "dependencies": [
10 "undici-types"
11 ]
12 },
13 "undici-types@7.10.0": {
14 "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="
15 }
16 }
17}
lib/log/ansi.ts+13-2
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1export const reset: string = `\x1b[0m`;1export const reset: string = `\x1b[0m`;
22
3export const bold: string = `\x1b[1m`;
4export const dim: string = `\x1b[2m`;
5export const resetWeight: string = `\x1b[22m`;
6
3export const fgReset: string = `\x1b[39m`;7export const fgReset: string = `\x1b[39m`;
4export const fgBlack: string = `\x1b[30m`;8export const fgBlack: string = `\x1b[30m`;
5export const fgRed: string = `\x1b[31m`;9export const fgRed: string = `\x1b[31m`;
...@@ -69,8 +73,15 @@ export function style(code: string, text: string): string {...@@ -69,8 +73,15 @@ export function style(code: string, text: string): string {
69 code = mergeStyles(code);73 code = mergeStyles(code);
70 const fg = /[\[;][39]/.test(code);74 const fg = /[\[;][39]/.test(code);
71 const bg = /[\[;](?:4|10)/.test(code);75 const bg = /[\[;](?:4|10)/.test(code);
76 const bold = /[\[;](?:1)[;m]/.test(code);
77 const dim = /[\[;](?:2)[;m]/.test(code);
72 return code + text +78 return code + text +
73 mergeStyles((fg ? fgReset : "") + (bg ? bgReset : ""));79 mergeStyles(
80 (fg ? fgReset : "") +
81 (bg ? bgReset : "") +
82 (bold ? resetWeight : "") +
83 (dim ? resetWeight : ""),
84 );
74}85}
7586
76/**87/**
...@@ -103,7 +114,7 @@ export function debugAnsi(text: string): string {...@@ -103,7 +114,7 @@ export function debugAnsi(text: string): string {
103 "fgBlue", "fgMagenta", "fgCyan", "fgWhite", "fgBrightBlack", "fgBrightRed",114 "fgBlue", "fgMagenta", "fgCyan", "fgWhite", "fgBrightBlack", "fgBrightRed",
104 "fgBrightGreen", "fgBrightYellow", "fgBrightBlue", "fgBrightMagenta",115 "fgBrightGreen", "fgBrightYellow", "fgBrightBlue", "fgBrightMagenta",
105 "fgBrightCyan", "fgBrightWhite", "clearToEndOfLine", "clearToStartOfLine",116 "fgBrightCyan", "fgBrightWhite", "clearToEndOfLine", "clearToStartOfLine",
106 "clearFullLine", "syncStart", "syncEnd"117 "clearFullLine", "syncStart", "syncEnd", "bold", "dim", "resetWeight"
107 ];118 ];
108 for (const key of verbaitim) {119 for (const key of verbaitim) {
109 text = text.replaceAll(120 text = text.replaceAll(
lib/log/headless.ts+17-6
...@@ -85,7 +85,12 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {...@@ -85,7 +85,12 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
85 let next = Infinity;85 let next = Infinity;
86 if (widgets[0]) {86 if (widgets[0]) {
87 for (let w = 0, { length } = widgets; w < length; w += 1) {87 for (let w = 0, { length } = widgets; w < length; w += 1) {
88 const outText = UNWRAP(widgets[w]).format(lastFlush, false);88 const outText = UNWRAP(widgets[w]).format({
89 now: lastFlush,
90 columns,
91 rows,
92 colors: true,
93 });
89 if (!outText) {94 if (!outText) {
90 widgets.splice(w, 1);95 widgets.splice(w, 1);
91 UNWRAP(internals.splice(w, 1)[0]).unsub?.();96 UNWRAP(internals.splice(w, 1)[0]).unsub?.();
...@@ -287,11 +292,16 @@ export function logger(env: LoggerOptions): log.Scope {...@@ -287,11 +292,16 @@ export function logger(env: LoggerOptions): log.Scope {
287 const logFn = "writeLog" in log292 const logFn = "writeLog" in log
288 ? log.writeLog293 ? log.writeLog
289 : ((prefix: string, level: Level, ...args: unknown[]) => {294 : ((prefix: string, level: Level, ...args: unknown[]) => {
290 log.writeLine(295 if (args.length === 0) return log.writeLine("");
291 (prefix296 const start = prefix
292 ? prefix + (level > 0 ? levels[level] + colon : "")297 ? prefix + (level > 0 ? levels[level] + colon : "")
293 : (levels[level] + colon)) + util.format(...args),298 : (levels[level] + colon);
294 );299 // TODO: make a more general "inspect" function
300 if (args[0] instanceof Error) {
301 log.writeLine(start + stack.format(args[0], colors));
302 } else {
303 log.writeLine(start + util.format(...args));
304 }
295 });305 });
296306
297 function scoped(name: string): log.Scope {307 function scoped(name: string): log.Scope {
...@@ -318,6 +328,7 @@ type Level = 0 | 1 | 2;...@@ -318,6 +328,7 @@ type Level = 0 | 1 | 2;
318import * as ansi from "lib/log/ansi.ts";328import * as ansi from "lib/log/ansi.ts";
319import * as string from "lib/string.ts";329import * as string from "lib/string.ts";
320import * as ts from "lib/ts.ts";330import * as ts from "lib/ts.ts";
331import * as stack from "lib/log/stack.ts";
321import * as util from "node:util";332import * as util from "node:util";
322import { UNWRAP } from "lib/assert.ts";333import { UNWRAP } from "lib/assert.ts";
323import type * as log from "../log.ts";334import type * as log from "../log.ts";
lib/log/stack.ts created+374
...@@ -0,0 +1,374 @@
1// the default stack trace formatters in node.js, deno, and bun are all
2// extremely low quality. they don't highlight the most important
3// information, and spend too much space on showing source code.
4//
5// this printer puts the error metadata at the top. each stack frame gets
6// two lines: the absolute path or url, and then one line of source code.
7// having five lines of context on just the top frame is useless, since
8// five lines is often not enough context to understand the entire block of
9// code. one line is all you need to remind yourself what the code on that
10// line was doing. and if you can see every frame's source code, you can
11// much more easily figure out what the bug is before opening the editor.
12//
13// to make understanding runtime code easier, `node:*` and `ext:deno` paths
14// are resolved into GitHub source code URLs, and node builtins will also
15// display their source code inline (via `process.binding("natives")`).
16//
17// with zero required imports, it can be safely included as a part of the
18// headless log and widget host without worrying about if the module can
19// import successfully. because of this, 'lib/log' uses this by default.
20//
21// parsing code is derived from clover's work on bun's DevServer, which she
22// derived from stacktracejs. this file is more refined than those two:
23// https://github.com/oven-sh/bun/blob/b5f31a6ee2f52ea67eabeb61f6e6e71215d55b26/src/bake/client/stack-trace.ts
24// https://github.com/stacktracejs/error-stack-parser/blob/9f33c224b5d7b607755eb277f9d51fcdb7287e24/error-stack-parser.js
25export interface Frame {
26 fn: string | null;
27 file: string | null;
28 line: number | null;
29 col: number | null;
30}
31
32const regexV8Stack = /^\s*at .*(\S+:\d+|\(native\))/m;
33const regexLocation = /(.+?)(?::(\d+))?(?::(\d+))?$/;
34
35export function parse(error: Error): null | Frame[] {
36 const stack = error?.stack;
37 if (typeof stack === "string") {
38 if (stack.match(regexV8Stack)) {
39 return parseV8OrIe(stack);
40 }
41 return parseJscOrSpidermonkey(stack);
42 }
43 return null;
44}
45
46export function format(error: Error, colors = false) {
47 const { name, message, stack: _, ...payload } = error;
48
49 const headingLine = (colors ? ansi.fgRed : "") +
50 (name && name !== "Error" ? `[${name}] ` : "") +
51 message + (colors ? ansi.fgReset : "") + "\n";
52
53 const payloadLines = Object.entries(payload)
54 .map(([k, v]) =>
55 " " + k +
56 (colors ? ansi.style(ansi.dim, ":") : ":") +
57 " " + JSON.stringify(v) + "\n"
58 )
59 .join("");
60
61 let frameLines = "[failed to parse stack trace string]";
62 let frames = parse(error) ?? [];
63 if ("Bun" in globalThis) {
64 frames = frames.filter((frame) =>
65 frame.file !== "native" || ![
66 "moduleEvaluation",
67 "moduleEvaluation",
68 "loadAndEvaluateModule",
69 "processTicksAndRejections",
70 ].includes(frame.fn)
71 );
72 }
73 if (frames.length) {
74 frameLines = frames.map((frame) => formatFrame(frame, colors)).join("\n");
75 }
76
77 return headingLine + payloadLines + frameLines + "\n";
78}
79
80export function formatFrame({ fn, file, line, col }: Frame, colors: boolean) {
81 let out = "";
82 if (file === "native") file = null;
83 const internal = !file || file.startsWith("node:") ||
84 file.startsWith("bun:") ||
85 file.startsWith("internal:") ||
86 file.startsWith("ext:");
87
88 if (file) {
89 if (file.startsWith("file://")) file = new URL(file).pathname;
90 const root = getPackageRoot(file);
91
92 // filename
93 if (
94 internal &&
95 // all bun internals do not line map
96 ("Bun" in globalThis ||
97 // deno transpiled internals do not line map
98 ("Deno" in globalThis && root && root[2].endsWith(".ts")))
99 ) {
100 line = 0;
101 }
102
103 if (colors) {
104 if (root) {
105 const [pathTo, project, sub] = root;
106 out += ansi.dim + pathTo +
107 ansi.mergeStyles(ansi.resetWeight + ansi.fgCyan) +
108 project + ansi.fgReset;
109 } else if (internal) {
110 out += ansi.dim;
111 }
112 out += root?.[2] ?? file;
113 } else out += root ? root.join("") : file;
114
115 // location
116 if (line) {
117 if (colors) out += ansi.mergeStyles(ansi.dim + ansi.fgYellow);
118 if (root?.[0]?.match(/^https:\/\/github.com/)) {
119 out += "#L" + line;
120 } else {
121 out += ":";
122 out += line;
123 if (line) {
124 out += ":";
125 out += col;
126 }
127 }
128 if (colors) {
129 out += fn
130 ? ansi.fgReset
131 : ansi.mergeStyles(ansi.fgReset + ansi.resetWeight);
132 }
133 } else if (fn) {
134 out += ansi.dim;
135 }
136 } else {
137 if (colors) out += ansi.dim;
138 out += "[unknown source]";
139 if (colors && !fn) out += ansi.resetWeight;
140 }
141
142 if (fn) {
143 if (internal) {
144 out += " at " + fn + ansi.resetWeight;
145 } else {
146 out += " at " +
147 ansi.mergeStyles(ansi.resetWeight + ansi.fgCyan) +
148 fn + ansi.fgReset;
149 }
150 }
151
152 preview: {
153 if (!file || !line) break preview;
154 const code = getSourceCode(file);
155 if (!code) break preview;
156 const text = code[line - 1];
157 if (!text) break preview;
158 out += "\n";
159 if (colors) {
160 if (col) {
161 // get a selection where the error happened
162 let l = col - 1;
163 let r = col;
164 while (l > 0 && !/[a-zA-Z0-9_$\s]/.test(text[l]!)) l -= 1;
165 while (l > 1 && /[a-zA-Z0-9_$]/.test(text[l - 1]!)) l -= 1;
166 while (r < text.length && /[a-zA-Z0-9_$]/.test(text[r]!)) r += 1;
167 // expand to special cases
168 let center = text.slice(l, r);
169 if (center === "new") {
170 r += text.slice(r).match(/\s*[a-zA-Z0-9_$]+/)?.[0]?.length ?? 0;
171 }
172 // print it all red, but use dim and bold to highlight the problem
173 const left = text.slice(0, l);
174 center = text.slice(l, r);
175 const right = text.slice(r);
176 out += (left
177 ? ansi.mergeStyles(ansi.fgRed + ansi.dim) + left +
178 ansi.mergeStyles(ansi.resetWeight + ansi.bold)
179 : ansi.mergeStyles(ansi.fgRed + ansi.bold)) +
180 center + (right
181 ? ansi.mergeStyles(ansi.resetWeight + ansi.dim) +
182 right
183 : "") +
184 ansi.mergeStyles(ansi.resetWeight + ansi.fgReset);
185 } else {
186 out += ansi.fgRed + text + ansi.fgReset;
187 }
188 } else {
189 out += text;
190 }
191 }
192
193 return out;
194}
195
196const existCache = new Map<string, boolean>();
197function getPackageRoot(absPath: string) {
198 const fs = nodeBuiltin("node:fs");
199 const path = nodeBuiltin("node:path");
200 const process = globalThis.process;
201 if (!fs || !path || !process) return null;
202
203 if (
204 "Bun" in globalThis &&
205 (absPath.startsWith("node:") || absPath.startsWith("internal:"))
206 ) {
207 return [
208 `https://github.com/oven-sh/bun/blob/bun-v${process.versions.bun}/src/js/`,
209 "",
210 absPath.replace(":", "/") + ".ts",
211 ];
212 }
213 if (absPath.startsWith("node:")) {
214 return [
215 `https://github.com/nodejs/node/blob/${process.version}/lib/`,
216 "",
217 absPath.slice(5) + ".js",
218 ];
219 }
220 if (
221 absPath.startsWith("ext:deno_") &&
222 process.versions.deno &&
223 // these files are transpiled without source maps.
224 !absPath.endsWith(".ts.js")
225 ) {
226 const node = absPath.startsWith("ext:deno_node");
227 const filename = node
228 ? absPath.slice("ext:deno_node/".length)
229 : absPath.slice("ext:deno_".length);
230 return [
231 `https://github.com/denoland/deno/blob/v${process.versions.deno}/ext/` +
232 (node ? "node/polyfills/" : ""),
233 "",
234 node && filename.endsWith(".ts.js") ? filename.slice(0, -3) : filename,
235 ];
236 }
237
238 if (!path.isAbsolute(absPath)) return null;
239 let dir = path.dirname(absPath);
240 do {
241 let exists = existCache.get(dir);
242 if (exists == null) {
243 existCache.set(
244 dir,
245 exists = fs.existsSync(path.join(dir, "package.json")) ||
246 fs.existsSync(path.join(dir, "deno.json")),
247 );
248 }
249 if (exists) {
250 return [
251 path.dirname(dir) + path.sep,
252 path.basename(dir),
253 absPath.slice(dir.length),
254 ] as const;
255 }
256 } while (dir !== (dir = path.dirname(dir)));
257 return "";
258}
259
260const codeCache = new Map<string, string[]>();
261function getSourceCode(file: string) {
262 const fs = nodeBuiltin("node:fs");
263 const path = nodeBuiltin("node:path");
264 if (!fs || !path) return null;
265 if (file.startsWith("node:")) {
266 // node.js has a secret binding for all the internal source codes
267 type Process = { binding(name: "natives"): Record<string, string> };
268 return (globalThis.process as unknown as Process)
269 ?.binding?.("natives")[file.slice(5)]?.split("\n") ?? null;
270 } else {
271 // bun and deno do not offer a way to get source code of internal modules,
272 // probably for good reason.
273 //
274 // i originally had an idea to download and transpile the version to
275 // provide a reference and source code, but it's undocumented how deno
276 // compiles their code and i don't think bun's line numbers actually remap
277 // correctly.
278 }
279 if (!path.isAbsolute(file)) return null;
280 let cache = codeCache.get(file);
281 if (cache == null) {
282 try {
283 codeCache.set(file, cache = fs.readFileSync(file, "utf-8").split("\n"));
284 } catch {}
285 }
286 return cache;
287}
288
289function parseV8OrIe(stack: string): Frame[] {
290 return stack
291 .split("\n")
292 .filter((line) => !!line.match(regexV8Stack))
293 .map(function (line) {
294 let sanitizedLine = line
295 .replace(/^\s+/, "")
296 .replace(/\(eval code/g, "(")
297 .replace(/^.*?\s+/, "");
298
299 // capture and preserve the parenthesized location "(/foo/my bar.js:12:87)" in
300 // case it has spaces in it, as the string is split on \s+ later on
301 let loc = sanitizedLine.match(/ (\(.+\)$)/);
302
303 // remove the parenthesized location from the line, if it was matched
304 sanitizedLine = loc ? sanitizedLine.replace(loc[0], "") : sanitizedLine;
305
306 // if a location was matched, pass it to extractLocation() otherwise pass all sanitizedLine
307 // because this line doesn't have function name
308 let locationParts = extractLocation(loc?.[1] ?? sanitizedLine);
309 let functionName = (loc && sanitizedLine) || undefined;
310 let fileName = ["eval", "<anonymous>"].indexOf(locationParts[0]) > -1
311 ? undefined
312 : locationParts[0];
313
314 return {
315 fn: functionName,
316 file: fileName,
317 line: 0 | locationParts[1],
318 col: 0 | locationParts[2],
319 } satisfies Frame;
320 });
321}
322
323function parseJscOrSpidermonkey(stack: string): Frame[] {
324 // Using string literal "\n" does not work in Safari.
325 return stack.split(/\n/g).map((source, i) => {
326 let fn = "";
327 let file: string | null = null;
328 let line: number | null = null;
329 let col: number | null = null;
330 if (source.endsWith("@")) {
331 // Safari eval frames only have function names and nothing else
332 fn = source.slice(0, -1);
333 } else if (source.indexOf("@") === -1 && source.indexOf(":") === -1) {
334 // Safari eval frames only have function names and nothing else
335 fn = source.endsWith("@") ? source.slice(0, -1) : source;
336 } else {
337 var functionNameRegex = /((.*".+"[^@]*)?[^@]*)(?:@)/;
338 var matches = source.match(functionNameRegex);
339 var functionName = matches && matches[1] ? matches[1] : undefined;
340 var locationParts = extractLocation(
341 source.replace(functionNameRegex, ""),
342 );
343 fn = functionName!;
344 file = locationParts[0];
345 line = 0 | locationParts[1];
346 col = 0 | locationParts[2];
347 }
348 if (fn === "module code") fn = null;
349 return {
350 fn,
351 file,
352 line,
353 col,
354 };
355 });
356}
357
358function extractLocation(urlLike: string) {
359 // Fail-fast but return locations like "(native)"
360 if (urlLike.indexOf(":") === -1) {
361 return [urlLike];
362 }
363
364 const parts: any = regexLocation.exec(urlLike.replace(/[()]/g, ""));
365 return [parts[1], parts[2] || undefined, parts[3] || undefined];
366}
367
368function nodeBuiltin(name: "node:path"): typeof import("node:path") | null;
369function nodeBuiltin(name: "node:fs"): typeof import("node:fs") | null;
370function nodeBuiltin(name: string) {
371 return globalThis.process?.getBuiltinModule(name) ?? null;
372}
373
374import * as ansi from "lib/log/ansi.ts";
package-lock.json+6-1
...@@ -4,7 +4,6 @@...@@ -4,7 +4,6 @@
4 "requires": true,4 "requires": true,
5 "packages": {5 "packages": {
6 "": {6 "": {
7 "name": "sitegen",
8 "dependencies": {7 "dependencies": {
9 "@hono/node-server": "^1.14.3",8 "@hono/node-server": "^1.14.3",
10 "@mdx-js/mdx": "^3.1.0",9 "@mdx-js/mdx": "^3.1.0",
...@@ -15,6 +14,7 @@...@@ -15,6 +14,7 @@
15 "esbuild": "^0.25.5",14 "esbuild": "^0.25.5",
16 "hls.js": "^1.6.5",15 "hls.js": "^1.6.5",
17 "hono": "^4.7.11",16 "hono": "^4.7.11",
17 "lib": "file:lib",
18 "marko": "^6.0.20",18 "marko": "^6.0.20",
19 "msgpackr": "^1.11.5",19 "msgpackr": "^1.11.5",
20 "puppeteer": "^24.10.1",20 "puppeteer": "^24.10.1",
...@@ -29,6 +29,7 @@...@@ -29,6 +29,7 @@
29 "typescript": "^5.8.3"29 "typescript": "^5.8.3"
30 }30 }
31 },31 },
32 "lib": {},
32 "node_modules/@ampproject/remapping": {33 "node_modules/@ampproject/remapping": {
33 "version": "2.3.0",34 "version": "2.3.0",
34 "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",35 "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
...@@ -3008,6 +3009,10 @@...@@ -3008,6 +3009,10 @@
3008 "lasso-caching-fs": "^1.0.0"3009 "lasso-caching-fs": "^1.0.0"
3009 }3010 }
3010 },3011 },
3012 "node_modules/lib": {
3013 "resolved": "lib",
3014 "link": true
3015 },
3011 "node_modules/lines-and-columns": {3016 "node_modules/lines-and-columns": {
3012 "version": "1.2.4",3017 "version": "1.2.4",
3013 "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",3018 "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
package.json+1
...@@ -10,6 +10,7 @@...@@ -10,6 +10,7 @@
10 "esbuild": "^0.25.5",10 "esbuild": "^0.25.5",
11 "hls.js": "^1.6.5",11 "hls.js": "^1.6.5",
12 "hono": "^4.7.11",12 "hono": "^4.7.11",
13 "lib": "file:lib",
13 "marko": "^6.0.20",14 "marko": "^6.0.20",
14 "msgpackr": "^1.11.5",15 "msgpackr": "^1.11.5",
15 "puppeteer": "^24.10.1",16 "puppeteer": "^24.10.1",
run.js+8-1
...@@ -46,7 +46,14 @@ console.info = log.info;...@@ -46,7 +46,14 @@ console.info = log.info;
46console.warn = log.warn;46console.warn = log.warn;
47console.error = log.error;47console.error = log.error;
48console.debug = log.scoped("debug");48console.debug = log.scoped("debug");
49console.log = console.debug;49console["log"] = console.debug;
50
51process.on("uncaughtException", (error) => {
52 console.error(error);
53 process.exit(1);
54});
55
56hot.load("node:fs").readFileSync(undefined);
5057
51// Load with hooks58// Load with hooks
52if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {59if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {