authorclover caruso <git@paperclover.net> 2026-10-15 03:03:37-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 02:48:27-07:00
log7cd13df720d3e3b96d29bdb8f8b2507840fa3b90
tree69f6faf2e745992d5e6f684274f3cb2a40a2631a
parent969a517e8ec98905a8cd28d8e415898c93145609
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs (~clover)

feat: spell checker

Use CSpell and `@clo/lib/incremental` to add spell checking to the development server. Assisted-by: gpt-6-astra Assisted-by: gpt-6.1-sol

53 files changed, 2102 insertions(+), 216 deletions(-)

cspell.json created+51
...@@ -0,0 +1,51 @@
1{
2 "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
3 "version": "0.2",
4 "language": "en,en-GB",
5 "unknownWords": "report-common-typos",
6 "minWordLength": 3,
7 "maxNumberOfProblems": 100000,
8 "maxDuplicateProblems": 100000,
9 "files": [
10 "{src,lib}/**/*.{ts,tsx,js,jsx,mjs,cjs,json,css,html,md,mdo,markodown,marko,txt,xml,svg,yml,yaml,sh,sql,dockerfile}"
11 ],
12 "ignorePaths": [
13 "src/blog/pages/**/{es,ko}.mdo",
14 "src/file-viewer/highlight-grammar/**",
15 "lib/incremental/example/out/**",
16 "lib/dist-npm/**",
17 "**/node_modules/**",
18 "**/.clover/**"
19 ],
20 "overrides": [
21 {
22 "filename": "**/*.marko",
23 "languageId": "html"
24 },
25 {
26 "filename": [
27 "**/*.mdo",
28 "**/*.markodown"
29 ],
30 "languageId": "markdown"
31 }
32 ],
33 "words": [
34 "fallthru",
35 "thru",
36 "thruout",
37 "welp"
38 ],
39 "dictionaries": [
40 "typescript",
41 "node",
42 "npm",
43 "html",
44 "css",
45 "fonts",
46 "filetypes",
47 "bash",
48 "lorem-ipsum"
49 ],
50 "numSuggestions": 3
51}
framework/lib/drizzle.ts+20-1
...@@ -13,6 +13,12 @@ export interface Database extends NodeSQLiteDatabase {...@@ -13,6 +13,12 @@ export interface Database extends NodeSQLiteDatabase {
13 * if any of that fails, nothing changed, and the module does not load.13 * if any of that fails, nothing changed, and the module does not load.
14 */14 */
15 migrate(table: SQLiteTable, version: number, steps?: Record<number, (db: NodeSQLiteDatabase) => void>): void;15 migrate(table: SQLiteTable, version: number, steps?: Record<number, (db: NodeSQLiteDatabase) => void>): void;
16 /**
17 * replace every row with those of a downloaded copy. the file stays the same,
18 * so connections that have it open read the new rows, and steps that read
19 * the old ones run again.
20 */
21 restore(copy: ReadableStream<Uint8Array>): Promise<void>;
16}22}
1723
18/** a database in `CLOVER_DB`, opened once. rows that steps read from it are tracked */24/** a database in `CLOVER_DB`, opened once. rows that steps read from it are tracked */
...@@ -28,6 +34,7 @@ export function getDatabase(file: string): Database {...@@ -28,6 +34,7 @@ export function getDatabase(file: string): Database {
28 db = Object.assign(drizzle({ client }), {34 db = Object.assign(drizzle({ client }), {
29 location,35 location,
30 migrate: (table: SQLiteTable, version: number, steps = {}) => migrate(location, table, version, steps),36 migrate: (table: SQLiteTable, version: number, steps = {}) => migrate(location, table, version, steps),
37 restore: (copy: ReadableStream<Uint8Array>) => restore(location, copy),
31 });38 });
32 databases.set(file, db);39 databases.set(file, db);
33 return db;40 return db;
...@@ -109,6 +116,18 @@ function migrate(...@@ -109,6 +116,18 @@ function migrate(
109 }116 }
110}117}
111118
119async function restore(location: string, copy: ReadableStream<Uint8Array>) {
120 // another restore can be landing at once, from this process or another
121 const temp = `${location}.${crypto.randomUUID()}.tmp`;
122 try {
123 await fs.promises.writeFile(temp, copy);
124 using snapshot = new DatabaseSync(temp, { readOnly: true });
125 await backup(snapshot, location);
126 } finally {
127 await fs.promises.rm(temp, { force: true });
128 }
129}
130
112/** the statements that create a table as drizzle defines it, the table first and then its indexes */131/** the statements that create a table as drizzle defines it, the table first and then its indexes */
113export function schema(table: SQLiteTable, as?: string): string[] {132export function schema(table: SQLiteTable, as?: string): string[] {
114 const { name, columns, indexes, foreignKeys, primaryKeys, uniqueConstraints, checks } = getTableConfig(table);133 const { name, columns, indexes, foreignKeys, primaryKeys, uniqueConstraints, checks } = getTableConfig(table);
...@@ -179,4 +198,4 @@ import {...@@ -179,4 +198,4 @@ import {
179} from "drizzle-orm/sqlite-core";198} from "drizzle-orm/sqlite-core";
180import * as fs from "node:fs";199import * as fs from "node:fs";
181import * as path from "node:path";200import * as path from "node:path";
182import { DatabaseSync } from "node:sqlite";201import { backup, DatabaseSync } from "node:sqlite";
lib/async.ts+2-2
...@@ -286,8 +286,8 @@ export function deferred<T>(promise: Promise<T>): Deferred<T> {...@@ -286,8 +286,8 @@ export function deferred<T>(promise: Promise<T>): Deferred<T> {
286 async [Symbol.asyncDispose]() {286 async [Symbol.asyncDispose]() {
287 if (!handled) await promise;287 if (!handled) await promise;
288 },288 },
289 then(onFufilled, onRejected) {289 then(onFulfilled, onRejected) {
290 return promise.finally(() => (handled = true)).then(onFufilled, onRejected);290 return promise.finally(() => (handled = true)).then(onFulfilled, onRejected);
291 },291 },
292 };292 };
293}293}
lib/incremental.ts+14-2
...@@ -14,7 +14,7 @@...@@ -14,7 +14,7 @@
14 * In the above, the page render steps act individually, only rebuilding the14 * In the above, the page render steps act individually, only rebuilding the
15 * post changed and the index. Notably, this will already handle a few fun cases:15 * post changed and the index. Notably, this will already handle a few fun cases:
16 *16 *
17 * - Adding a new file in `posts/` does not rebuild any pages excep the new one.17 * - Adding a new file in `posts/` does not rebuild any pages except the new one.
18 * - Changing post contents but not frontmatter does not re-run `renderHomepage`18 * - Changing post contents but not frontmatter does not re-run `renderHomepage`
19 *19 *
20 * Since return values of steps are cached, [i need to continue writing this]20 * Since return values of steps are cached, [i need to continue writing this]
...@@ -2466,7 +2466,7 @@ function groupErrors(entries: ErrorEntry[]): Error[] {...@@ -2466,7 +2466,7 @@ function groupErrors(entries: ErrorEntry[]): Error[] {
2466 * within a {@linkcode Builder} function.2466 * within a {@linkcode Builder} function.
2467 */2467 */
2468export interface Input {2468export interface Input {
2469 /** global key for the changable object, with enough information to re-query. */2469 /** global key for the changeable object, with enough information to re-query. */
2470 url: string;2470 url: string;
2471 /** "file", "directory", "missing", "other", or a source's protocol. */2471 /** "file", "directory", "missing", "other", or a source's protocol. */
2472 kind: string;2472 kind: string;
...@@ -4940,6 +4940,8 @@ const pageHeader = "clover-page";...@@ -4940,6 +4940,8 @@ const pageHeader = "clover-page";
4940const libPath = "/.clover/lib/";4940const libPath = "/.clover/lib/";
4941const socketPath = "/.clover/socket";4941const socketPath = "/.clover/socket";
4942const graphPath = "/.clover/graph";4942const graphPath = "/.clover/graph";
4943/** where a page posts a file, with its `:line:col`, to open in the editor */
4944const openPath = "/.clover/open";
4943/** the visualization alone, which can be watched before anything else is served */4945/** the visualization alone, which can be watched before anything else is served */
4944const homePath = "/.clover";4946const homePath = "/.clover";
4945const libDir = url.fileURLToPath(new URL("./", import.meta.url));4947const libDir = url.fileURLToPath(new URL("./", import.meta.url));
...@@ -5047,6 +5049,15 @@ export class Debugger {...@@ -5047,6 +5049,15 @@ export class Debugger {
5047 };5049 };
5048 return Response.json(snapshot, { headers: { "cache-control": "no-store" } });5050 return Response.json(snapshot, { headers: { "cache-control": "no-store" } });
5049 }5051 }
5052 if (pathname === openPath) {
5053 // any page in the browser can post here, but only this server's may open files
5054 if (request.method !== "POST" || request.headers.get("origin") !== `http://${request.headers.get("host")}`) {
5055 return new Response(null, { status: 403 });
5056 }
5057 // a terminal editor runs until it quits, which the page does not wait for
5058 void subprocess.launchEditor(path.resolve(this.#graph.root, await request.text())).catch(log.error);
5059 return new Response(null, { status: 204 });
5060 }
5050 const check = request.headers.has(pageHeader);5061 const check = request.headers.has(pageHeader);
5051 const navigated = request.headers.get("sec-fetch-dest") === "document";5062 const navigated = request.headers.get("sec-fetch-dest") === "document";
5052 // until a build is served, a page shows that build, and reloads once there is something to serve5063 // until a build is served, a page shows that build, and reloads once there is something to serve
...@@ -5380,6 +5391,7 @@ import type * as node from "./node.ts";...@@ -5380,6 +5391,7 @@ import type * as node from "./node.ts";
5380import * as progress from "./progress.ts";5391import * as progress from "./progress.ts";
5381import * as repl from "./repl.ts";5392import * as repl from "./repl.ts";
5382import * as ansi from "./string/ansi.ts";5393import * as ansi from "./string/ansi.ts";
5394import * as subprocess from "./subprocess.ts";
5383import * as ts from "./ts.ts";5395import * as ts from "./ts.ts";
53845396
5385import { AsyncLocalStorage } from "node:async_hooks";5397import { AsyncLocalStorage } from "node:async_hooks";
lib/incremental/debugger.test.ts+41
...@@ -91,6 +91,45 @@ test("the library is served as javascript, and only its typescript", async () =>...@@ -91,6 +91,45 @@ test("the library is served as javascript, and only its typescript", async () =>
91 assert.equal(await (await get("/.clover/lib/%2e%2e/run.js")).text(), "from the site");91 assert.equal(await (await get("/.clover/lib/%2e%2e/run.js")).text(), "from the site");
92});92});
9393
94test.skipIf(os.platform() === "win32")(
95 "a page opens a file in the editor, which only this server's pages may",
96 async () => {
97 const graph = new incremental.Graph({ db: null });
98 const respond = new incremental.Debugger(graph).respond(async () => new Response("from the site"));
99 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clover-editor-"));
100 const editor = path.join(dir, "code");
101 fs.writeFileSync(
102 editor,
103 `#!${process.execPath}\nrequire("node:fs").writeFileSync(process.argv[1] + ".argv", JSON.stringify(process.argv.slice(2)));\n`,
104 { mode: 0o755 },
105 );
106 const open = (origin: string) =>
107 respond(
108 new Request("http://localhost:3000/.clover/open", {
109 method: "POST",
110 headers: { host: "localhost:3000", origin },
111 body: "lib/incremental.ts:12:4",
112 }),
113 { node: progress.nullNode, remoteAddress: "127.0.0.1" },
114 );
115 process.env.LAUNCH_EDITOR = editor;
116 try {
117 assert.equal((await open("http://elsewhere.example")).status, 403);
118 assert.equal((await open("http://localhost:3000")).status, 204);
119 while (!fs.existsSync(editor + ".argv")) await new Promise((resolve) => setTimeout(resolve, 10));
120 // a relative path is from the project
121 assert.deepEqual(JSON.parse(fs.readFileSync(editor + ".argv", "utf8")), [
122 "-r",
123 "-g",
124 `${path.join(graph.root, "lib/incremental.ts")}:12:4`,
125 ]);
126 } finally {
127 delete process.env.LAUNCH_EDITOR;
128 fs.rmSync(dir, { recursive: true, force: true });
129 }
130 },
131);
132
94// ## the socket133// ## the socket
95test("a page hears of every build, its progress, and its errors", async () => {134test("a page hears of every build, its progress, and its errors", async () => {
96 const debug = new incremental.Debugger(new incremental.Graph({ db: null }));135 const debug = new incremental.Debugger(new incremental.Graph({ db: null }));
...@@ -351,7 +390,9 @@ async function connect(base: string, headers: Record<string, string>) {...@@ -351,7 +390,9 @@ async function connect(base: string, headers: Record<string, string>) {
351}390}
352391
353import assert from "node:assert/strict";392import assert from "node:assert/strict";
393import * as fs from "node:fs";
354import * as net from "node:net";394import * as net from "node:net";
395import * as os from "node:os";
355import * as path from "node:path";396import * as path from "node:path";
356import * as consumers from "node:stream/consumers";397import * as consumers from "node:stream/consumers";
357import * as url from "node:url";398import * as url from "node:url";
lib/incremental/steps.test.ts+7-7
...@@ -346,7 +346,7 @@ test("a step catching another step's error is cached against that error", async...@@ -346,7 +346,7 @@ test("a step catching another step's error is cached against that error", async
346});346});
347347
348test("warnings fail the build without stopping the step", async () => {348test("warnings fail the build without stopping the step", async () => {
349 await using p = await project({ "post.md": "teh cat" });349 await using p = await project({ "post.md": "the cat" });
350 let runs = 0;350 let runs = 0;
351 const graph = p.graph();351 const graph = p.graph();
352 const site = () =>352 const site = () =>
...@@ -354,16 +354,16 @@ test("warnings fail the build without stopping the step", async () => {...@@ -354,16 +354,16 @@ test("warnings fail the build without stopping the step", async () => {
354 ref.step(async function spellcheck(b) {354 ref.step(async function spellcheck(b) {
355 runs++;355 runs++;
356 const text = await b.readFile("post.md").text();356 const text = await b.readFile("post.md").text();
357 const col = text.indexOf("teh");357 const col = text.indexOf("cat");
358 if (col !== -1) {358 if (col !== -1) {
359 b.warn(Object.assign(new Error("misspelled 'the'"), { file: "post.md", line: 1, col: col + 1 }));359 b.warn(Object.assign(new Error("flag 'cat'"), { file: "post.md", line: 1, col: col + 1 }));
360 }360 }
361 return text.toUpperCase();361 return text.toUpperCase();
362 }).read());362 }).read());
363363
364 for (const cached of [false, true]) {364 for (const cached of [false, true]) {
365 const error = await site().catch((error) => error) as incremental.BuildError;365 const error = await site().catch((error) => error) as incremental.BuildError;
366 assert.equal(error.value, "TEH CAT");366 assert.equal(error.value, "THE CAT");
367 const [lint] = error.errors as Array<Error & { fatal: boolean; cached: boolean; line: number }>;367 const [lint] = error.errors as Array<Error & { fatal: boolean; cached: boolean; line: number }>;
368 assert.equal(lint!.message, "misspelled 'the'");368 assert.equal(lint!.message, "misspelled 'the'");
369 assert.equal(lint!.fatal, false);369 assert.equal(lint!.fatal, false);
...@@ -402,12 +402,12 @@ test("errors thrown from the same place are grouped, and identical ones merge",...@@ -402,12 +402,12 @@ test("errors thrown from the same place are grouped, and identical ones merge",
402});402});
403403
404test("warnings about other places are said apart, even when reported from the same line", async () => {404test("warnings about other places are said apart, even when reported from the same line", async () => {
405 await using p = await project({ "post.md": "teh cat teh" });405 await using p = await project({ "post.md": "a cat meow" });
406 const error = await build(p.graph(), (ref) =>406 const error = await build(p.graph(), (ref) =>
407 ref.step(async function spellcheck(b) {407 ref.step(async function spellcheck(b) {
408 const text = await b.readFile("post.md").text();408 const text = await b.readFile("post.md").text();
409 for (const match of text.matchAll(/teh/g)) {409 for (const match of text.matchAll(/meow/g)) {
410 b.warn(Object.assign(new Error("misspelled 'the'"), { file: "post.md", line: 1, col: match.index + 1 }));410 b.warn(Object.assign(new Error("flag 'meow'"), { file: "post.md", line: 1, col: match.index + 1 }));
411 }411 }
412 }).read()).catch((error) => error) as incremental.BuildError;412 }).read()).catch((error) => error) as incremental.BuildError;
413 assert.deepEqual(error.errors.map((warning) => (warning as Error & { col: number }).col), [1, 9]);413 assert.deepEqual(error.errors.map((warning) => (warning as Error & { col: number }).col), [1, 9]);
lib/incremental/visualization.ts+80-28
...@@ -117,6 +117,15 @@ const states: Record<Exclude<incremental.StepEvent["state"], "reused" | "kept">...@@ -117,6 +117,15 @@ const states: Record<Exclude<incremental.StepEvent["state"], "reused" | "kept">
117 failed: "#ff3b3b",117 failed: "#ff3b3b",
118};118};
119119
120/** a problem or note that is open, and not folding away */
121const unfolded = "[open]:not(.folding)";
122
123/** how a fold comes to rest, fast and then exponentially slower, like the graph's motion */
124const ease = `linear(${
125 Array.from({ length: 17 }, (_, i) => ((1 - Math.exp(-6 * i / 16)) / (1 - Math.exp(-6))).toFixed(4))
126})`;
127const easeMs = 360;
128
120const tabButtons = tabs.map((name) =>129const tabButtons = tabs.map((name) =>
121 `<button type=button role=tab id=tab-${name} aria-controls=view-${name}>${name}</button>`130 `<button type=button role=tab id=tab-${name} aria-controls=view-${name}>${name}</button>`
122).join("");131).join("");
...@@ -158,7 +167,7 @@ shadow.innerHTML = `<style>${css()}</style>...@@ -158,7 +167,7 @@ shadow.innerHTML = `<style>${css()}</style>
158 <div class=slot></div>167 <div class=slot></div>
159 <div class=problems></div>168 <div class=problems></div>
160</dialog>169</dialog>
161<div id=tip class=tip role=tooltip><span class=title></span><span class=line></span></div>`;170<div id=tip class=tip role=tooltip><span class=title></span><span class=status></span></div>`;
162171
163const find = <E extends Element = HTMLElement>(selector: string) => UNWRAP(shadow.querySelector<E>(selector));172const find = <E extends Element = HTMLElement>(selector: string) => UNWRAP(shadow.querySelector<E>(selector));
164const button = find<HTMLButtonElement>(".clover");173const button = find<HTMLButtonElement>(".clover");
...@@ -195,7 +204,7 @@ function render() {...@@ -195,7 +204,7 @@ function render() {
195 button.setAttribute("aria-expanded", String(visible));204 button.setAttribute("aria-expanded", String(visible));
196 const ago = last && string.formatDurationLetters(-Math.round((Date.now() - last.at) / 1000));205 const ago = last && string.formatDurationLetters(-Math.round((Date.now() - last.at) / 1000));
197 find(".tip .title").innerHTML = `<b>${title(state)}</b>${ago ? `, last build ${ago}` : ""}`;206 find(".tip .title").innerHTML = `<b>${title(state)}</b>${ago ? `, last build ${ago}` : ""}`;
198 find(".tip .line").textContent = status?.statusLine ?? "";207 find(".tip .status").textContent = status?.statusLine ?? "";
199208
200 panel.hidden = !visible;209 panel.hidden = !visible;
201 panel.dataset.tab = tab;210 panel.dataset.tab = tab;
...@@ -263,7 +272,7 @@ const fold = `<button type=button class=fold><svg viewBox="0 0 12 12" aria-hidde...@@ -263,7 +272,7 @@ const fold = `<button type=button class=fold><svg viewBox="0 0 12 12" aria-hidde
263 <path d="M1.5 1.5h9v9h-9zM3.5 6h5" /><path class=unfold d="M6 3.5v5" /></svg></button>`;272 <path d="M1.5 1.5h9v9h-9zM3.5 6h5" /><path class=unfold d="M6 3.5v5" /></svg></button>`;
264273
265function showFold() {274function showFold() {
266 const open = shadow.querySelector("details.diagnostic[open]") !== null;275 const open = shadow.querySelector(`details.diagnostic${unfolded}`) !== null;
267 for (const button of shadow.querySelectorAll<HTMLElement>(".fold")) {276 for (const button of shadow.querySelectorAll<HTMLElement>(".fold")) {
268 button.classList.toggle("folded", !open);277 button.classList.toggle("folded", !open);
269 button.setAttribute("aria-label", open ? "fold every problem" : "unfold every problem");278 button.setAttribute("aria-label", open ? "fold every problem" : "unfold every problem");
...@@ -291,10 +300,8 @@ function title(state: keyof typeof titles): string {...@@ -291,10 +300,8 @@ function title(state: keyof typeof titles): string {
291function diagnostic(of: stack.Diagnostic): string {300function diagnostic(of: stack.Diagnostic): string {
292 const head = (label: string, text: string) =>301 const head = (label: string, text: string) =>
293 `<b>${string.escapeHtmlContent(label)}</b><span class=colon>:</span> ${string.escapeHtmlContent(text)}`;302 `<b>${string.escapeHtmlContent(label)}</b><span class=colon>:</span> ${string.escapeHtmlContent(text)}`;
294 const where = of.location303 const where = of.location ? `<span class=where>${fileName(of.location)}</span>` : "";
295 ? `<span class=where>${string.escapeHtmlContent((of.location.file ?? unknown) + positionOf(of.location))}</span>`304 let html = `<summary class=head>${chevron}${head(of.label, of.message)}${where}</summary>`;
296 : "";
297 let html = `<summary class=head>${head(of.label, of.message)}${where}</summary>`;
298 if (of.location) html += excerpt(of.location, true);305 if (of.location) html += excerpt(of.location, true);
299 if (of.fields.length > 0) {306 if (of.fields.length > 0) {
300 const fields = of.fields.map(([name, json]) =>307 const fields = of.fields.map(([name, json]) =>
...@@ -304,7 +311,7 @@ function diagnostic(of: stack.Diagnostic): string {...@@ -304,7 +311,7 @@ function diagnostic(of: stack.Diagnostic): string {
304 }311 }
305 for (const note of of.notes) {312 for (const note of of.notes) {
306 html += note.frames.length > 0313 html += note.frames.length > 0
307 ? `<details class=note><summary>${head("note", note.text)}</summary>`314 ? `<details class=note><summary>${chevron}${head("note", note.text)}</summary>`
308 + `<div class=frames>${note.frames.map((frame) => excerpt(frame, false)).join("")}</div></details>`315 + `<div class=frames>${note.frames.map((frame) => excerpt(frame, false)).join("")}</div></details>`
309 : `<div class=note><p class=head>${head("note", note.text)}</p>`316 : `<div class=note><p class=head>${head("note", note.text)}</p>`
310 + `${note.location ? excerpt(note.location, false) : ""}</div>`;317 + `${note.location ? excerpt(note.location, false) : ""}</div>`;
...@@ -318,7 +325,7 @@ function diagnostic(of: stack.Diagnostic): string {...@@ -318,7 +325,7 @@ function diagnostic(of: stack.Diagnostic): string {
318 const items = of.similar.map(({ message, from }) =>325 const items = of.similar.map(({ message, from }) =>
319 `<li>${string.escapeHtmlContent(message)} <span class=from>${string.escapeHtmlContent(from)}</span></li>`326 `<li>${string.escapeHtmlContent(message)} <span class=from>${string.escapeHtmlContent(from)}</span></li>`
320 );327 );
321 html += `<details class=note><summary>and ${of.similar.length} more like it</summary>`328 html += `<details class=note><summary>${chevron}and ${of.similar.length} more like it</summary>`
322 + `<ul class=similar>${items.join("")}</ul></details>`;329 + `<ul class=similar>${items.join("")}</ul></details>`;
323 }330 }
324 return `<details class="diagnostic ${of.severity}"${folded ? "" : " open"}>${html}</details>`;331 return `<details class="diagnostic ${of.severity}"${folded ? "" : " open"}>${html}</details>`;
...@@ -326,6 +333,27 @@ function diagnostic(of: stack.Diagnostic): string {...@@ -326,6 +333,27 @@ function diagnostic(of: stack.Diagnostic): string {
326333
327const unknown = "[unknown source]";334const unknown = "[unknown source]";
328335
336/** what folds a problem or note, so the rest of its line can be selected */
337const chevron =
338 `<span class=chevron><svg viewBox="0 0 12 12" aria-hidden=true><path d="M4.5 2.5 8 6l-3.5 3.5" /></svg></span>`;
339
340/** fold or unfold, keeping what folds away in sight until it is gone */
341function setOpen(details: HTMLDetailsElement, open: boolean) {
342 if (details.matches(unfolded) === open) return;
343 const from = details.offsetHeight;
344 for (const animation of details.getAnimations()) animation.cancel();
345 details.classList.toggle("folding", !open);
346 details.open = open;
347 const to = details.offsetHeight;
348 details.open = true;
349 const duration = matchMedia("(prefers-reduced-motion:reduce)").matches ? 0 : easeMs;
350 details.animate({ height: [`${from}px`, `${to}px`], overflow: ["clip", "clip"] }, { duration, easing: ease })
351 .finished.then(() => {
352 details.classList.remove("folding");
353 details.open = open;
354 }, () => {});
355}
356
329/** where a place is in its file: `:line:col`, or an anchor in a url */357/** where a place is in its file: `:line:col`, or an anchor in a url */
330function positionOf({ file, line, col }: stack.Excerpt): string {358function positionOf({ file, line, col }: stack.Excerpt): string {
331 if (!line) return "";359 if (!line) return "";
...@@ -335,12 +363,7 @@ function positionOf({ file, line, col }: stack.Excerpt): string {...@@ -335,12 +363,7 @@ function positionOf({ file, line, col }: stack.Excerpt): string {
335/** a place and its code: the line it points at, and with `context`, the lines around it, faded */363/** a place and its code: the line it points at, and with `context`, the lines around it, faded */
336function excerpt(place: stack.Excerpt, context: boolean): string {364function excerpt(place: stack.Excerpt, context: boolean): string {
337 const { file, line, fn, code, first, span, internal } = place;365 const { file, line, fn, code, first, span, internal } = place;
338 const url = file?.startsWith("https://") ? file + positionOf(place) : null;366 const where = fileName(place) + (fn ? ` at <span class=fn>${string.escapeHtmlContent(fn)}</span>` : "");
339 // the source of a runtime's own code is a link away
340 const named = url
341 ? `<a href="${string.escapeHtmlAttr(url)}" target=_blank>${string.escapeHtmlContent(url)}</a>`
342 : string.escapeHtmlContent(file ?? unknown) + `<span class=pos>${positionOf(place)}</span>`;
343 const where = named + (fn ? ` at <span class=fn>${string.escapeHtmlContent(fn)}</span>` : "");
344367
345 // blank lines around the line it points at say nothing368 // blank lines around the line it points at say nothing
346 const shown = code.map((text, i) => ({ text, n: first + i }))369 const shown = code.map((text, i) => ({ text, n: first + i }))
...@@ -359,6 +382,17 @@ function excerpt(place: stack.Excerpt, context: boolean): string {...@@ -359,6 +382,17 @@ function excerpt(place: stack.Excerpt, context: boolean): string {
359 + (lines.length > 0 ? `<pre>${lines.join("")}</pre>` : "") + "</figure>";382 + (lines.length > 0 ? `<pre>${lines.join("")}</pre>` : "") + "</figure>";
360}383}
361384
385/** a place's file, which opens in the editor, or for a runtime's own code, links to its source */
386function fileName(place: stack.Excerpt): string {
387 if (place.file === null) return unknown;
388 const at = place.file + positionOf(place);
389 if (at.startsWith("https://")) {
390 return `<a href="${string.escapeHtmlAttr(at)}" target=_blank>${string.escapeHtmlContent(at)}</a>`;
391 }
392 return `<button type=button class=path data-open="${string.escapeHtmlAttr(at)}">`
393 + `${string.escapeHtmlContent(place.file)}<span class=pos>${positionOf(place)}</span></button>`;
394}
395
362/** a longer line, like minified code, shows this much around what it points at */396/** a longer line, like minified code, shows this much around what it points at */
363const widest = 160;397const widest = 160;
364398
...@@ -1282,9 +1316,22 @@ dialog.addEventListener("cancel", (event) => {...@@ -1282,9 +1316,22 @@ dialog.addEventListener("cancel", (event) => {
1282 dismiss();1316 dismiss();
1283});1317});
1284shadow.addEventListener("click", (event) => {1318shadow.addEventListener("click", (event) => {
1285 if (!(event.target as Element).closest(".fold")) return;1319 const target = event.target as Element;
1286 folded = shadow.querySelector("details.diagnostic[open]") !== null;1320 const file = target.closest<HTMLElement>("[data-open]")?.dataset.open;
1287 for (const problem of shadow.querySelectorAll<HTMLDetailsElement>("details.diagnostic")) problem.open = !folded;1321 if (file) return void fetch("/.clover/open", { method: "POST", body: file });
1322 const summary = target.closest("summary");
1323 if (summary) {
1324 event.preventDefault();
1325 // a click from the keyboard has no `detail`
1326 if ((event as MouseEvent).detail > 0 && !target.closest(".chevron")) return;
1327 const details = summary.parentElement as HTMLDetailsElement;
1328 setOpen(details, !details.matches(unfolded));
1329 } else if (target.closest(".fold")) {
1330 folded = shadow.querySelector(`details.diagnostic${unfolded}`) !== null;
1331 for (const problem of shadow.querySelectorAll<HTMLDetailsElement>("details.diagnostic")) setOpen(problem, !folded);
1332 } else {
1333 return;
1334 }
1288 showFold();1335 showFold();
1289});1336});
1290// a problem folded by hand can change what the button does1337// a problem folded by hand can change what the button does
...@@ -1337,8 +1384,8 @@ button:focus-visible{outline:2px solid #0cff10;outline-offset:1px}...@@ -1337,8 +1384,8 @@ button:focus-visible{outline:2px solid #0cff10;outline-offset:1px}
1337@keyframes blink{50%{opacity:.2}}1384@keyframes blink{50%{opacity:.2}}
1338.live{position:fixed;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}1385.live{position:fixed;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
13391386
1340.tip{display:none;top:8px;right:46px;max-width:300px;padding:5px 8px;border:1px solid #000;background:#fff;1387.tip{display:none;top:8px;right:46px;max-width:min(300px,calc(100vw - 54px));padding:5px 8px;border:1px solid #000;
1341 color:#000;line-height:1.2}1388 background:#fff;color:#000;line-height:1.2;overflow-wrap:anywhere}
1342/* one stroke over the border's own, so its sides are as wide as the border and join it cleanly */1389/* one stroke over the border's own, so its sides are as wide as the border and join it cleanly */
1343.tip::before{content:"";position:absolute;top:5px;left:100%;width:8px;height:18px;1390.tip::before{content:"";position:absolute;top:5px;left:100%;width:8px;height:18px;
1344 background:url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='18'><rect y='2' width='1' height='14' fill='%23fff'/><path d='M.5 0v2l7 7-7 7v2' fill='%23fff' stroke='%23000'/></svg>")}1391 background:url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='18'><rect y='2' width='1' height='14' fill='%23fff'/><path d='M.5 0v2l7 7-7 7v2' fill='%23fff' stroke='%23000'/></svg>")}
...@@ -1388,7 +1435,7 @@ summary.head{display:block}...@@ -1388,7 +1435,7 @@ summary.head{display:block}
1388.note>summary,.note>.head{color:#bbb}1435.note>summary,.note>.head{color:#bbb}
1389.colon{color:#999}1436.colon{color:#999}
1390.where{display:none;font-size:12px;line-height:1.4;color:#999;white-space:normal}1437.where{display:none;font-size:12px;line-height:1.4;color:#999;white-space:normal}
1391details:not([open])>summary>.where{display:block}1438:is(details:not([open]),.folding)>summary>.where{display:block}
1392/* the first problem leaves room for the button that folds them all */1439/* the first problem leaves room for the button that folds them all */
1393.problems>details:first-of-type>summary{padding-right:26px}1440.problems>details:first-of-type>summary{padding-right:26px}
1394.fold{position:absolute;top:-2px;right:-2px;display:grid;place-items:center;width:22px;height:22px;min-height:0;1441.fold{position:absolute;top:-2px;right:-2px;display:grid;place-items:center;width:22px;height:22px;min-height:0;
...@@ -1401,7 +1448,9 @@ details:not([open])>summary>.where{display:block}...@@ -1401,7 +1448,9 @@ details:not([open])>summary>.where{display:block}
1401figcaption{padding:3px 8px 0;font-size:12px;line-height:1.4;color:#bbb;overflow-wrap:anywhere}1448figcaption{padding:3px 8px 0;font-size:12px;line-height:1.4;color:#bbb;overflow-wrap:anywhere}
1402.pos{color:#888}1449.pos{color:#888}
1403figcaption a{color:inherit;text-decoration:none}1450figcaption a{color:inherit;text-decoration:none}
1404figcaption a:hover{text-decoration:underline}1451figcaption a:hover,.path:hover{text-decoration:underline}
1452.path{all:unset;cursor:pointer}
1453.path:hover{background:none}
1405.fn{color:#3ee6ff}1454.fn{color:#3ee6ff}
1406.excerpt pre{margin:0;padding:1px 0 3px;overflow-x:auto;font-size:12px;line-height:1.45;color:#e6e6e6;tab-size:2}1455.excerpt pre{margin:0;padding:1px 0 3px;overflow-x:auto;font-size:12px;line-height:1.45;color:#e6e6e6;tab-size:2}
1407.line{display:block;padding-right:8px;white-space:pre}1456.line{display:block;padding-right:8px;white-space:pre}
...@@ -1424,10 +1473,13 @@ figcaption a:hover{text-decoration:underline}...@@ -1424,10 +1473,13 @@ figcaption a:hover{text-decoration:underline}
1424.fields dt{color:#999}1473.fields dt{color:#999}
1425.fields dt::after{content:":"}1474.fields dt::after{content:":"}
1426.fields dd{margin:0;overflow-wrap:anywhere}1475.fields dd{margin:0;overflow-wrap:anywhere}
1427summary{cursor:pointer;list-style:none;line-height:1.35}1476summary{list-style:none;line-height:1.35}
1428summary::-webkit-details-marker{display:none}1477summary::-webkit-details-marker{display:none}
1429.note>summary::before{content:"\\25B8";display:inline-block;width:14px;color:#999}1478.chevron{display:inline-block;width:14px;color:#999;cursor:pointer;user-select:none}
1430.note[open]>summary::before{content:"\\25BE"}1479.chevron:hover{color:#fff}
1480.chevron svg{width:10px;height:10px;vertical-align:-1px;fill:none;stroke:currentColor;stroke-width:1.5;
1481 transition:rotate ${easeMs}ms ${ease}}
1482${unfolded}>summary>.chevron svg{rotate:90deg}
1431.frames{display:flex;flex-direction:column;gap:4px;margin-top:4px}1483.frames{display:flex;flex-direction:column;gap:4px;margin-top:4px}
1432.nested{padding-left:10px;border-left:2px solid #333}1484.nested{padding-left:10px;border-left:2px solid #333}
1433.similar{margin:4px 0 0;padding-left:30px;font-size:12px;line-height:1.45}1485.similar{margin:4px 0 0;padding-left:30px;font-size:12px;line-height:1.45}
...@@ -1445,18 +1497,18 @@ dialog .excerpt pre,dialog figcaption,dialog .fields{font-size:13px}...@@ -1445,18 +1497,18 @@ dialog .excerpt pre,dialog figcaption,dialog .fields{font-size:13px}
14451497
1446#view-graph{position:relative}1498#view-graph{position:relative}
1447canvas{display:block;width:100%;height:min(60vh,480px);cursor:grab;touch-action:none}1499canvas{display:block;width:100%;height:min(60vh,480px);cursor:grab;touch-action:none}
1448.panel.max canvas{height:calc(100vh - 68px)}1500.panel.max canvas{height:calc(100vh - 48px)}
1449.legend{position:absolute;left:0;bottom:0;display:flex;flex-wrap:wrap;gap:2px 10px;padding:4px 6px;1501.legend{position:absolute;left:0;bottom:0;display:flex;flex-wrap:wrap;gap:2px 10px;padding:4px 6px;
1450 background:#000c;font-size:11px;color:#ccc;pointer-events:none}1502 background:#000c;font-size:11px;color:#ccc;pointer-events:none}
1451.legend i{display:inline-block;box-sizing:border-box;width:9px;height:9px;margin-right:4px;vertical-align:-1px}1503.legend i{display:inline-block;box-sizing:border-box;width:9px;height:9px;margin-right:4px;vertical-align:-1px}
1452.point{position:absolute;width:max-content;max-width:320px;padding:4px 6px;background:#fff;color:#000;font-size:12px;1504.point{position:absolute;width:max-content;max-width:320px;padding:4px 6px;background:#fff;color:#000;font-size:12px;
1453 line-height:1.2;overflow-wrap:anywhere;pointer-events:none}1505 line-height:1.2;overflow-wrap:anywhere;pointer-events:none}
1454/* a build's progress floats over the graph, which shows through */1506/* a build's progress floats over the graph, which shows through */
1455.panel[data-tab=graph] .body{position:relative}1507.panel[data-tab=graph] .body{position:relative;padding:0}
1456.panel[data-tab=graph] .slot{position:absolute;top:10px;left:10px;right:10px;z-index:1;max-height:45%;1508.panel[data-tab=graph] .slot{position:absolute;top:10px;left:10px;right:10px;z-index:1;max-height:45%;
1457 overflow:hidden;padding:8px 10px;background:#000c;border:2px solid #999}1509 overflow:hidden;padding:8px 10px;background:#000c;border:2px solid #999}
14581510
1459@media (prefers-reduced-motion:reduce){.clover.stale svg{animation:none}}1511@media (prefers-reduced-motion:reduce){.clover.stale svg{animation:none}.chevron svg{transition:none}}
1460`;1512`;
1461}1513}
14621514
lib/log.ts+8-8
...@@ -189,7 +189,7 @@ export function replaceGlobalWidgetHost(host: WidgetHost) {...@@ -189,7 +189,7 @@ export function replaceGlobalWidgetHost(host: WidgetHost) {
189 global.widget = {189 global.widget = {
190 frozen: false,190 frozen: false,
191 source: stack.capture()[0]?.file191 source: stack.capture()[0]?.file
192 ?? ("untracable call to replaceGlobalWidgetHost in " + import.meta.url),192 ?? ("untraceable call to replaceGlobalWidgetHost in " + import.meta.url),
193 host,193 host,
194 version: 0,194 version: 0,
195 };195 };
...@@ -237,7 +237,7 @@ export function startWidget<T extends WidgetOptions>(...@@ -237,7 +237,7 @@ export function startWidget<T extends WidgetOptions>(
237}237}
238238
239/**239/**
240 * no built-in prefix, formatting, or newlline. ensures the text does not interweave.240 * no built-in prefix, formatting, or newline. ensures the text does not interweave.
241 * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.241 * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.
242 */242 */
243export function writeOutput(text: string) {243export function writeOutput(text: string) {
...@@ -406,10 +406,10 @@ export interface TerminalWidgetHostOptions {...@@ -406,10 +406,10 @@ export interface TerminalWidgetHostOptions {
406 * will patch `process.std{out,err}` to ensure write calls properly get a406 * will patch `process.std{out,err}` to ensure write calls properly get a
407 * draw lock.407 * draw lock.
408 *408 *
409 * this lock can be cleared by deactiving all widgets, or by calling409 * this lock can be cleared by deactivating all widgets, or by calling
410 * {@linkcode getDrawLock} to force `@clo/lib` to stop drawing.410 * {@linkcode getDrawLock} to force `@clo/lib` to stop drawing.
411 *411 *
412 * currently, this lock must be able to be synchronously aquired at any412 * currently, this lock must be able to be synchronously acquired at any
413 * point. if you desire an async locking function, please contact me so we413 * point. if you desire an async locking function, please contact me so we
414 * can design how it would work.414 * can design how it would work.
415 */415 */
...@@ -446,12 +446,12 @@ export interface TerminalWidgetHostOptions {...@@ -446,12 +446,12 @@ export interface TerminalWidgetHostOptions {
446 */446 */
447export interface TerminalLock {447export interface TerminalLock {
448 /**448 /**
449 * recieves ANSI escape sequences for interactive data, as well as error log449 * receives ANSI escape sequences for interactive data, as well as error log
450 * content from `writeError` (should flush immediately). the interactive450 * content from `writeError` (should flush immediately). the interactive
451 * stream and the error stream are the same: stderr.451 * stream and the error stream are the same: stderr.
452 */452 */
453 writeInteractive(text: string): void;453 writeInteractive(text: string): void;
454 /** recieves log content from `write` (pre-buffered; should flush immediately) */454 /** receives log content from `write` (pre-buffered; should flush immediately) */
455 writeOutput(text: string): void;455 writeOutput(text: string): void;
456 /**456 /**
457 * subscribe to the terminal size. the callback must fire synchronously457 * subscribe to the terminal size. the callback must fire synchronously
...@@ -492,7 +492,7 @@ export interface WidgetHost {...@@ -492,7 +492,7 @@ export interface WidgetHost {
492 /**492 /**
493 * what does this widget host support?493 * what does this widget host support?
494 * - color: ANSI escape sequences are displayed and not stripped494 * - color: ANSI escape sequences are displayed and not stripped
495 * - widget: `startWidget` can meaninglyful display the widget495 * - widget: `startWidget` can meaningfully display the widget
496 */496 */
497 capabilities: ReadonlyArray<"widget" | "color">;497 capabilities: ReadonlyArray<"widget" | "color">;
498}498}
...@@ -1860,7 +1860,7 @@ export function defaultNodeProcessWidgetHost(...@@ -1860,7 +1860,7 @@ export function defaultNodeProcessWidgetHost(
18601860
1861 // non-node runtimes will typically implement console in a way that1861 // non-node runtimes will typically implement console in a way that
1862 // doesn't use `node:process`, so it must also get patched. this is1862 // doesn't use `node:process`, so it must also get patched. this is
1863 // okay because the lock is re-enterant.1863 // okay because the lock is reentrant.
1864 function patchSyncMethod<T, A extends unknown[]>(1864 function patchSyncMethod<T, A extends unknown[]>(
1865 fn: (this: T, ...args: A) => void,1865 fn: (this: T, ...args: A) => void,
1866 ) {1866 ) {
lib/meta.ts+1-1
...@@ -6,7 +6,7 @@ export interface Meta {...@@ -6,7 +6,7 @@ export interface Meta {
6 description?: string | null;6 description?: string | null;
7 /** automatically added for static renders from the 'pages' folders. */7 /** automatically added for static renders from the 'pages' folders. */
8 canonical?: string | null;8 canonical?: string | null;
9 /** add `<link rel="alternate" ... />`. Object keys are interpretted as9 /** add `<link rel="alternate" ... />`. Object keys are interpreted as
10 * mime types if they contain a slash, otherwise seen as an alternative language. */10 * mime types if they contain a slash, otherwise seen as an alternative language. */
11 alternates?: Alternate[] | Record<string, string> | null;11 alternates?: Alternate[] | Record<string, string> | null;
1212
lib/progress.ts+9-9
...@@ -58,7 +58,7 @@ export function start(text: string, opts?: StartOptions): Node {...@@ -58,7 +58,7 @@ export function start(text: string, opts?: StartOptions): Node {
58/**58/**
59 * a reference point to create sub-items. this interface is satisfied by59 * a reference point to create sub-items. this interface is satisfied by
60 * - the `progress` module itself60 * - the `progress` module itself
61 * - a node recieved by `p.start("label")`61 * - a node received by `p.start("label")`
62 * - a headless progress runner from `progress.headless()`62 * - a headless progress runner from `progress.headless()`
63 *63 *
64 * prefer taking `Ref` as a function parameter, so that the function can be64 * prefer taking `Ref` as a function parameter, so that the function can be
...@@ -71,7 +71,7 @@ export function start(text: string, opts?: StartOptions): Node {...@@ -71,7 +71,7 @@ export function start(text: string, opts?: StartOptions): Node {
71 * }71 * }
72 * ```72 * ```
73 *73 *
74 * sometimes it makes sense to have an API recieve `Node` instead of `Ref`,74 * sometimes it makes sense to have an API receive `Node` instead of `Ref`,
75 * specifically if the caller should have some control over the node name.75 * specifically if the caller should have some control over the node name.
76 *76 *
77 * ```ts77 * ```ts
...@@ -90,7 +90,7 @@ export interface Ref {...@@ -90,7 +90,7 @@ export interface Ref {
90}90}
9191
92/**92/**
93 * control for a progress nooe. all fields are primed with setters to trigger93 * control for a progress node. all fields are primed with setters to trigger
94 * ui dispatch automatically. to make tools that use `progress.ts` more94 * ui dispatch automatically. to make tools that use `progress.ts` more
95 * modular to a custom progress implementation, many fields are optional.95 * modular to a custom progress implementation, many fields are optional.
96 */96 */
...@@ -102,7 +102,7 @@ export interface Node extends Ref, Disposable {...@@ -102,7 +102,7 @@ export interface Node extends Ref, Disposable {
102 /**102 /**
103 * number of items completed. by default this is a count of items, but the103 * number of items completed. by default this is a count of items, but the
104 * `units` property can be passed to `start` to change how the `value` is104 * `units` property can be passed to `start` to change how the `value` is
105 * interpretted when displaying this progress node.105 * interpreted when displaying this progress node.
106 */106 */
107 value: number;107 value: number;
108 /**108 /**
...@@ -127,7 +127,7 @@ export interface Node extends Ref, Disposable {...@@ -127,7 +127,7 @@ export interface Node extends Ref, Disposable {
127 estimatedTime?: number | null;127 estimatedTime?: number | null;
128 /**128 /**
129 * default `true`. `false` will hides a bar + printing total. this is useful129 * default `true`. `false` will hides a bar + printing total. this is useful
130 * for preseving auto-end behavior of `inc` without showing the total.130 * for preserving auto-end behavior of `inc` without showing the total.
131 */131 */
132 showTotal?: boolean;132 showTotal?: boolean;
133 /**133 /**
...@@ -153,14 +153,14 @@ export interface StartOptions {...@@ -153,14 +153,14 @@ export interface StartOptions {
153 /** when set above `0`, it is shown with a progress bar. */153 /** when set above `0`, it is shown with a progress bar. */
154 total?: number | undefined | null;154 total?: number | undefined | null;
155 /**155 /**
156 * presets for common formattings of `value` and `total`.156 * presets for common formats of `value` and `total`.
157 * @default "count"157 * @default "count"
158 */158 */
159 units?: "count" | "bytes" | "percent";159 units?: "count" | "bytes" | "percent";
160 /**160 /**
161 * customize how an estimate is given for progress bars. to make161 * customize how an estimate is given for progress bars. to make
162 * `Node.estimatedTime` mutable, pass `false` here.162 * `Node.estimatedTime` mutable, pass `false` here.
163 * @default `null`, which is equivilent to `new progress.Ema()`163 * @default `null`, which is equivalent to `new progress.Ema()`
164 */164 */
165 estimateCompletion?: EstimationAlgorithm | boolean | null;165 estimateCompletion?: EstimationAlgorithm | boolean | null;
166 /**166 /**
...@@ -175,7 +175,7 @@ export interface StartOptions {...@@ -175,7 +175,7 @@ export interface StartOptions {
175 hidden?: boolean | undefined | null;175 hidden?: boolean | undefined | null;
176 /**176 /**
177 * default `true`. `false` will hides a bar + printing estimate,177 * default `true`. `false` will hides a bar + printing estimate,
178 * but preseving auto-end behavior of `inc`178 * but preserving auto-end behavior of `inc`
179 */179 */
180 showTotal?: boolean | undefined | null;180 showTotal?: boolean | undefined | null;
181 /** specify sorting for children */181 /** specify sorting for children */
...@@ -1192,7 +1192,7 @@ export const contentType = "application/x-clover-progress";...@@ -1192,7 +1192,7 @@ export const contentType = "application/x-clover-progress";
1192/**1192/**
1193 * opaque but JSON-compatible serialized state, either containing the entire1193 * opaque but JSON-compatible serialized state, either containing the entire
1194 * progress tree or just a delta update. this data structure always be future1194 * progress tree or just a delta update. this data structure always be future
1195 * and backwards compatbile with changes to this library. if a breaking change1195 * and backwards compatible with changes to this library. if a breaking change
1196 * is made, the APIs in this file will be renamed.1196 * is made, the APIs in this file will be renamed.
1197 *1197 *
1198 * for details on the format, read the source of {@linkcode encodeEventStream} or1198 * for details on the format, read the source of {@linkcode encodeEventStream} or
lib/queue.ts+4-4
...@@ -27,8 +27,8 @@ export interface Job<out T> {...@@ -27,8 +27,8 @@ export interface Job<out T> {
27 priority?: number;27 priority?: number;
28 /** If not `true`, the given AbortSignal will never abort. */28 /** If not `true`, the given AbortSignal will never abort. */
29 cancelable?: boolean;29 cancelable?: boolean;
30 /** @default false */30 /** @default true */
31 causeCancelations?: boolean;31 causeCancellations?: boolean;
32}32}
3333
34export class PriorityQueue {34export class PriorityQueue {
...@@ -65,7 +65,7 @@ export class PriorityQueue {...@@ -65,7 +65,7 @@ export class PriorityQueue {
65 if (this.canRun(job)) {65 if (this.canRun(job)) {
66 this.startJob(job);66 this.startJob(job);
67 } else {67 } else {
68 if (job.causeCancelations ?? true) {68 if (job.causeCancellations ?? true) {
69 // Attempt to cancel other jobs to allow pushing this one up.69 // Attempt to cancel other jobs to allow pushing this one up.
70 let remain = this.coresRemain;70 let remain = this.coresRemain;
71 for (71 for (
...@@ -112,7 +112,7 @@ export class PriorityQueue {...@@ -112,7 +112,7 @@ export class PriorityQueue {
112 .finally(() => {112 .finally(() => {
113 this.coresRemain += job.cores;113 this.coresRemain += job.cores;
114 const index = active.indexOf(job);114 const index = active.indexOf(job);
115 if (index === -1) throw new Error("fyuc");115 if (index === -1) throw new Error("completed job is missing from the active queue");
116 active.splice(index, 1);116 active.splice(index, 1);
117 if (this.coresRemain % 1 !== 0) {117 if (this.coresRemain % 1 !== 0) {
118 this.coresRemain = active.reduce(118 this.coresRemain = active.reduce(
lib/readme.changes.md+6-2
...@@ -5,6 +5,7 @@...@@ -5,6 +5,7 @@
5### breaking5### breaking
66
7- `subprocess` is rewritten around web streams.7- `subprocess` is rewritten around web streams.
8- `queue.Job`'s cancellation option is renamed to `causeCancellations`.
89
9### features10### features
1011
...@@ -18,6 +19,9 @@...@@ -18,6 +19,9 @@
18 pages this machine asks for: each build's status, progress, and errors, and19 pages this machine asks for: each build's status, progress, and errors, and
19 the graph of its steps.20 the graph of its steps.
20- `stream.lines` reads a byte stream as lines of text.21- `stream.lines` reads a byte stream as lines of text.
22- `subprocess.launchEditor` shows a file in the editor the user has open, at a
23 line and column, ported from vite's `launch-editor`.
24- `subprocess.spawn`'s `shell` option runs a command through the system shell.
21- `subprocess`'s `progress` option hands a `progress.Ref` to a child process,25- `subprocess`'s `progress` option hands a `progress.Ref` to a child process,
22 which becomes the global root of `progress` there. the child's global log is26 which becomes the global root of `progress` there. the child's global log is
23 written by the parent as well. `progress.childEnv` and `progress.attachChild`27 written by the parent as well. `progress.childEnv` and `progress.attachChild`
...@@ -86,7 +90,7 @@...@@ -86,7 +90,7 @@
86 - `meta.toTags` creates an object compatible with TanStack Router90 - `meta.toTags` creates an object compatible with TanStack Router
87 - the `extra` object now takes an array of React-like elements among some91 - the `extra` object now takes an array of React-like elements among some
88 other supported forms.92 other supported forms.
89- `log` and `progress` have recieved so many bug-fix changes that it is worth93- `log` and `progress` have received so many bug-fix changes that it is worth
90 ensuring your application edge cases still work as expected.94 ensuring your application edge cases still work as expected.
91- `string.formatByteSize` formats differently.95- `string.formatByteSize` formats differently.
9296
...@@ -159,7 +163,7 @@ deprecations without removals:...@@ -159,7 +163,7 @@ deprecations without removals:
159- `progress.ts` node gains `node.log.write("word ");` to write a message without163- `progress.ts` node gains `node.log.write("word ");` to write a message without
160 a newline.164 a newline.
161- `async.delay` handles timers longer than 23 days and `Infinity`.165- `async.delay` handles timers longer than 23 days and `Infinity`.
162- `Lru.revive` recieves bug fixes. this function previously didn't really work.166- `Lru.revive` receives bug fixes. this function previously didn't really work.
163- `ansi` gets more cursor control constants167- `ansi` gets more cursor control constants
164168
165## v3169## v3
lib/readme.md+2-2
...@@ -31,7 +31,7 @@ depend on each other. there are no external dependencies; code is easy to audit....@@ -31,7 +31,7 @@ depend on each other. there are no external dependencies; code is easy to audit.
31- `queue` - priority queue with rescheduling31- `queue` - priority queue with rescheduling
32- `repl` - interactive prompt for long running programs32- `repl` - interactive prompt for long running programs
33- `stream` - ReadableStream helpers33- `stream` - ReadableStream helpers
34- `string` - trival helpers34- `string` - trivial helpers
35- `string/ansi` - cursor and color constants35- `string/ansi` - cursor and color constants
36- `subprocess` - rebuilt api for `node:child_process`36- `subprocess` - rebuilt api for `node:child_process`
37- `subprocess/ffmpeg` - spawn and parse progress output37- `subprocess/ffmpeg` - spawn and parse progress output
...@@ -72,7 +72,7 @@ named as single words so they can be easily imported....@@ -72,7 +72,7 @@ named as single words so they can be easily imported.
7272
73## versioning73## versioning
7474
75these libraries are published as is, **without versioning or maintainance75these libraries are published as is, **without versioning or maintenance
76guarantees**. every version is a breaking major release. please upgrade your76guarantees**. every version is a breaking major release. please upgrade your
77libraries with intent, considering api changes in [readme.changes.md].77libraries with intent, considering api changes in [readme.changes.md].
78individual modules may annotate their intended api and usage stability.78individual modules may annotate their intended api and usage stability.
lib/stream.test.ts+2-2
...@@ -256,8 +256,8 @@ describe("lines", () => {...@@ -256,8 +256,8 @@ describe("lines", () => {
256 });256 });
257257
258 test("a character split between chunks", async () => {258 test("a character split between chunks", async () => {
259 const bytes = string.encodeUtf8("caf\u00e9\n");259 const bytes = string.encodeUtf8("cat\u00e9\n");
260 assert.deepEqual(await collect([bytes.subarray(0, 4), bytes.subarray(4)]), ["caf\u00e9"]);260 assert.deepEqual(await collect([bytes.subarray(0, 4), bytes.subarray(4)]), ["cat\u00e9"]);
261 });261 });
262262
263 test("leaving early cancels the stream", async () => {263 test("leaving early cancels the stream", async () => {
lib/string/ansi.ts+2-2
...@@ -231,7 +231,7 @@ export function mergeStyles(code: string): string {...@@ -231,7 +231,7 @@ export function mergeStyles(code: string): string {
231 */231 */
232export function debugAnsi(text: string): string {232export function debugAnsi(text: string): string {
233 // deno-fmt-ignore233 // deno-fmt-ignore
234 const verbaitim: (keyof typeof self)[] = [234 const verbatim: (keyof typeof self)[] = [
235 "fgCyan",235 "fgCyan",
236 "fgBlue",236 "fgBlue",
237 "bgReset",237 "bgReset",
...@@ -278,7 +278,7 @@ export function debugAnsi(text: string): string {...@@ -278,7 +278,7 @@ export function debugAnsi(text: string): string {
278 "cursorSave",278 "cursorSave",
279 "cursorRestore",279 "cursorRestore",
280 ];280 ];
281 for (const key of verbaitim) {281 for (const key of verbatim) {
282 text = text.replaceAll(282 text = text.replaceAll(
283 String(self[key]),283 String(self[key]),
284 style(key.includes("cursor") ? fgBlue : fgCyan, `\${${key}}`),284 style(key.includes("cursor") ? fgBlue : fgCyan, `\${${key}}`),
lib/subprocess.test.ts+93-1
...@@ -227,12 +227,104 @@ describe("progress", () => {...@@ -227,12 +227,104 @@ describe("progress", () => {
227 });227 });
228});228});
229229
230// windows runs editors through cmd.exe, which cannot execute these stubs
231describe.skipIf(os.platform() === "win32")("launchEditor", () => {
232 const root = fs.mkdtempSync(path.join(os.tmpdir(), "clover-editor-"));
233 // a directory with a space in it, since an editor command is shell-quoted
234 const stubDir = path.join(root, "editor stubs");
235 fs.mkdirSync(stubDir);
236 afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
237
238 /** an executable standing in for an editor, which records the arguments it is given */
239 function stub(name: string, exitCode = 0) {
240 const file = path.join(stubDir, name);
241 fs.writeFileSync(
242 file,
243 `#!${process.execPath}\n`
244 + `require("node:fs").writeFileSync(process.argv[1] + ".argv", JSON.stringify(process.argv.slice(2)));\n`
245 + `process.exit(${exitCode});\n`,
246 { mode: 0o755 },
247 );
248 return {
249 command: `'${file}'`,
250 argv: (): string[] => JSON.parse(fs.readFileSync(file + ".argv", "utf8")),
251 };
252 }
253
254 const file = path.resolve("lib/subprocess.ts");
255
256 test("vscode takes a position after the file name", async () => {
257 const code = stub("code");
258 await subprocess.launchEditor(file, { editor: code.command, line: 12, column: 4 });
259 assert.deepEqual(code.argv(), ["-r", "-g", `${file}:12:4`]);
260 });
261
262 test("$LAUNCH_EDITOR is used, and the position may be in the file name", async () => {
263 const code = stub("Code.cmd");
264 process.env.LAUNCH_EDITOR = code.command;
265 try {
266 await subprocess.launchEditor(`${file}:7`);
267 } finally {
268 delete process.env.LAUNCH_EDITOR;
269 }
270 assert.deepEqual(code.argv(), ["-r", "-g", `${file}:7:1`]);
271 });
272
273 test("jetbrains editors take flags, including the 64-bit builds", async () => {
274 const idea = stub("idea64");
275 await subprocess.launchEditor(`${file}:12:4`, { editor: idea.command });
276 assert.deepEqual(idea.argv(), ["--line", "12", "--column", "4", file]);
277 });
278
279 test("vim moves its cursor with a command", async () => {
280 const vim = stub("vim");
281 await subprocess.launchEditor(file, { editor: vim.command, line: 12, column: 4 });
282 assert.deepEqual(vim.argv(), [`+call cursor(12, 4)`, file]);
283 });
284
285 test("arguments in the editor command come before the file", async () => {
286 const code = stub("code");
287 await subprocess.launchEditor(file, { editor: `${code.command} --wait`, line: 2 });
288 assert.deepEqual(code.argv(), ["--wait", "-r", "-g", `${file}:2:1`]);
289 });
290
291 test("a file:// url is a path", async () => {
292 const zed = stub("zed");
293 await subprocess.launchEditor(`${url.pathToFileURL(file)}:3:9`, { editor: zed.command });
294 assert.deepEqual(zed.argv(), [`${file}:3:9`]);
295 });
296
297 test("an unknown editor named by the user gets the position as arguments", async () => {
298 const mine = stub("fancy-editor");
299 await subprocess.launchEditor(file, { editor: mine.command, line: 12, column: 4 });
300 assert.deepEqual(mine.argv(), [file, "12", "4"]);
301
302 await subprocess.launchEditor(file, { editor: mine.command });
303 assert.deepEqual(mine.argv(), [file]);
304 });
305
306 test("an editor that is not installed explains itself", async () => {
307 await assert.rejects(subprocess.launchEditor(file, { editor: "clover-lib-missing-editor" }), {
308 message: "could not open subprocess.ts in clover-lib-missing-editor: not found in $PATH",
309 });
310 });
311
312 test("an editor that fails is reported with its exit status", async () => {
313 const broken = stub("code", 3);
314 const error = await subprocess.launchEditor(file, { editor: broken.command }).catch((error) => error);
315 assert.equal(exception.message(error), "could not open subprocess.ts in code");
316 assert.ok(error.cause instanceof subprocess.ExitError);
317 assert.equal(error.cause.code, 3);
318 });
319});
320
230import { strict as assert } from "node:assert";321import { strict as assert } from "node:assert";
231import * as fs from "node:fs";322import * as fs from "node:fs";
232import * as os from "node:os";323import * as os from "node:os";
233import * as path from "node:path";324import * as path from "node:path";
234import * as process from "node:process";325import * as process from "node:process";
235import { describe, test } from "vitest";326import * as url from "node:url";
327import { afterAll, describe, test } from "vitest";
236import { UNWRAP } from "./assert.ts";328import { UNWRAP } from "./assert.ts";
237import * as async from "./async.ts";329import * as async from "./async.ts";
238import * as exception from "./exception.ts";330import * as exception from "./exception.ts";
lib/subprocess.ts+257-1
...@@ -33,7 +33,7 @@ export async function execute(...@@ -33,7 +33,7 @@ export async function execute(
33 */33 */
34export function spawn<const O extends Options>(cmd: Command, opts?: O): Subprocess<O> {34export function spawn<const O extends Options>(cmd: Command, opts?: O): Subprocess<O> {
35 const { stdin = "ignore", stdout = "inherit", stderr = "inherit" }: Options = opts ?? {};35 const { stdin = "ignore", stdout = "inherit", stderr = "inherit" }: Options = opts ?? {};
36 const { cwd, env, signal, ipc, progress: handoff }: Options = opts ?? {};36 const { cwd, env, signal, ipc, shell, progress: handoff }: Options = opts ?? {};
37 signal?.throwIfAborted();37 signal?.throwIfAborted();
3838
39 const stdio: Array<Stdio | "ipc"> = [stdin, stdout, stderr];39 const stdio: Array<Stdio | "ipc"> = [stdin, stdout, stderr];
...@@ -42,6 +42,7 @@ export function spawn<const O extends Options>(cmd: Command, opts?: O): Subproce...@@ -42,6 +42,7 @@ export function spawn<const O extends Options>(cmd: Command, opts?: O): Subproce
4242
43 const child = child_process.spawn(cmd[0], cmd.slice(1), {43 const child = child_process.spawn(cmd[0], cmd.slice(1), {
44 cwd,44 cwd,
45 shell,
45 env: { ...process.env, ...env, ...handoff && progress.ipcChildEnvironment(progressFd) },46 env: { ...process.env, ...env, ...handoff && progress.ipcChildEnvironment(progressFd) },
46 stdio,47 stdio,
47 });48 });
...@@ -116,6 +117,8 @@ export interface Options {...@@ -116,6 +117,8 @@ export interface Options {
116 stdout?: Stdio;117 stdout?: Stdio;
117 /** @default "inherit" */118 /** @default "inherit" */
118 stderr?: Stdio;119 stderr?: Stdio;
120 /** run `cmd` through the system shell, which does not escape what it is given */
121 shell?: boolean;
119 /** open a channel for messages with a child that is node.js too, where it is `process` */122 /** open a channel for messages with a child that is node.js too, where it is `process` */
120 ipc?: boolean;123 ipc?: boolean;
121 /** for a subprocess using `@clo/lib/process`, setup progress IPC. */124 /** for a subprocess using `@clo/lib/process`, setup progress IPC. */
...@@ -245,12 +248,265 @@ interface ProcessLookup {...@@ -245,12 +248,265 @@ interface ProcessLookup {
245 command: string;248 command: string;
246}249}
247250
251/**
252 * open a file in the text editor that is already in front of the user, at a
253 * line and column, a port of vite's `launch-editor`.
254 *
255 * `file` is a path, a path with `:line:column` appended, or a `file://` url.
256 * resolves once the editor process exits, which is immediate for the editors
257 * that hand the file to a window already open.
258 *
259 * what happens to a path that does not exist is up to the editor; most open an
260 * empty buffer for it.
261 */
262export async function launchEditor(file: string, options: EditorOptions = {}): Promise<void> {
263 const { fileName, line: fileLine, column: fileColumn } = parsePosition(file);
264 const line = options.line ?? fileLine;
265 const column = options.column ?? fileColumn ?? 1;
266 const windows = os.platform() === "win32";
267
268 // cmd.exe would happily run an editor off of a remote share
269 if (windows && fileName.startsWith("\\\\")) {
270 throw new Error("cannot open a UNC path on windows");
271 }
272
273 const specified = options.editor ?? process.env.LAUNCH_EDITOR;
274 const [editor, ...args] = specified ? splitCommand(specified) : await runningEditor();
275 if (!editor) throw new Error(`cannot open ${path.basename(fileName)}: no editor found, set $EDITOR`);
276
277 // an editor this port has no arguments for gets a position only when the user
278 // named it, since the wrong flags make editors create files or hang on input.
279 const position = line === undefined ? null : positionArgs(editor, fileName, line, column)
280 ?? (specified ? [fileName, `${line}`, `${column}`] : null);
281 args.push(...position ?? [fileName]);
282
283 const inTerminal = terminalEditors.has(editorName(editor));
284 // two editors sharing the terminal would both read from its input
285 if (inTerminal && currentEditor) {
286 currentEditor.replaced = true;
287 await currentEditor.proc[Symbol.asyncDispose]();
288 }
289
290 const proc = spawn(windows ? [windowsLaunchCommand(editor, args)] : [editor, ...args], {
291 stdin: "inherit",
292 shell: windows,
293 });
294 const entry: typeof currentEditor = inTerminal ? { proc } : null;
295 if (entry) currentEditor = entry;
296 try {
297 await proc.wait();
298 } catch (error) {
299 if (entry?.replaced) return;
300 const why = exception.code(error) === "ENOENT" ? ": not found in $PATH" : "";
301 throw new Error(`could not open ${path.basename(fileName)} in ${editorName(editor)}${why}`, { cause: error });
302 } finally {
303 if (currentEditor === entry) currentEditor = null;
304 }
305}
306
307export interface EditorOptions {
308 /**
309 * the editor to run, as a shell command so that arguments may be included.
310 * defaults to `$LAUNCH_EDITOR`, then the editor this machine has running,
311 * then `$VISUAL` or `$EDITOR`. an editor named here that this module has no
312 * arguments for is passed the line and column as two more arguments.
313 */
314 editor?: string;
315 /** 1-based, taking precedence over a `:line` in the file name */
316 line?: number;
317 /** 1-based, defaulting to the first column */
318 column?: number;
319}
320
321/** the editor holding the terminal's input, which only one of them may */
322let currentEditor: { proc: Subprocess; replaced?: boolean } | null = null;
323
324/** `file.ts:12:4`, where the line and column are optional, or a `file://` url */
325function parsePosition(file: string) {
326 if (file.startsWith("file://")) file = url.fileURLToPath(file);
327 const [suffix, line, column] = /:(\d+)(?::(\d+))?$/.exec(file) ?? [];
328 return {
329 fileName: path.resolve(suffix ? file.slice(0, -suffix.length) : file),
330 line: line ? Number(line) : undefined,
331 column: column ? Number(column) : undefined,
332 };
333}
334
335/** splits `code -w` or `'/Volumes/My Disk/zed' --new`, quotes included */
336function splitCommand(command: string): string[] {
337 return command.match(/'[^']*'|"[^"]*"|\S+/g)
338 ?.map((arg) => arg.replace(/^(['"])(.*)\1$/s, "$2")) ?? [];
339}
340
341/** the name an editor is known by, however it was spelled or installed */
342function editorName(editor: string): string {
343 // `.exe`/`.cmd` and jetbrains' `64` suffix both name the editor before them
344 return path.basename(editor).toLowerCase().replace(/\.(exe|cmd|bat)$/, "").replace(/64$/, "");
345}
346
347/** arguments placing an editor's cursor at a position, or `null` if unknown */
348function positionArgs(editor: string, fileName: string, line: number, column: number): string[] | null {
349 switch (argStyles.get(editorName(editor))) {
350 case "vscode":
351 return ["-r", "-g", `${fileName}:${line}:${column}`];
352 case "suffix":
353 return [`${fileName}:${line}:${column}`];
354 case "jetbrains":
355 return ["--line", `${line}`, "--column", `${column}`, fileName];
356 case "mate":
357 return ["--line", `${line}`, fileName];
358 case "vim":
359 return [`+call cursor(${line}, ${column})`, fileName];
360 case "plus":
361 return [`+${line}`, fileName];
362 case "emacs":
363 return [`+${line}:${column}`, fileName];
364 case "notepad++":
365 return [`-n${line}`, `-c${column}`, fileName];
366 default:
367 return null;
368 }
369}
370
371/**
372 * cmd.exe would expand `&`, `|`, and friends out of a file name before the
373 * editor sees it, so they are escaped with `^`. `%var%` is expanded before the
374 * escapes are consumed and a line break starts another command: neither can be
375 * written safely, and a path containing one is refused instead.
376 */
377function windowsLaunchCommand(editor: string, args: string[]): string {
378 if (args.some((arg) => /[\r\n%]/.test(arg))) {
379 throw new Error(`cannot open a path containing a line break or '%' on windows`);
380 }
381 return [editor, ...args.map((arg) => arg.replace(/[&|<>,;=^]/g, "^$&"))]
382 // an escape has to be quoted, and the quotes around it escaped as well
383 .map((arg) => arg.includes("^") ? `^"${arg}^"` : arg.includes(" ") ? `"${arg}"` : arg)
384 .join(" ");
385}
386
387/**
388 * the editor this machine has open, found in the process list, falling back to
389 * the classic environment variables. the result is a command, since an editor
390 * in `$EDITOR` may carry arguments.
391 */
392async function runningEditor(): Promise<string[]> {
393 try {
394 const platform = os.platform();
395 if (platform === "darwin") {
396 const processes = (await execute(["ps", "x", "-o", "comm="])).split("\n");
397 for (const [app, cli] of macosEditors) {
398 // an app outside of /Applications matches too, and runs from where it is
399 const running = processes.find((name) => name.endsWith(app));
400 if (running) return [cli || running];
401 }
402 } else if (platform === "linux") {
403 const command = ["ps", "x", "--no-heading", "-o", "comm", "--sort=comm"] as const;
404 const processes = new Set((await execute(command)).split("\n").map((l) => l.trim()));
405 for (const [name, cli] of linuxEditors) {
406 if (processes.has(name) || processes.has(name + ".sh")) return [cli || name];
407 }
408 } else if (platform === "win32") {
409 const query = "select executablepath from win32_process where executablepath is not null";
410 const processes = await execute([
411 "powershell",
412 "-NoProfile",
413 "-Command",
414 `[Console]::OutputEncoding=[Text.Encoding]::UTF8;`
415 + `Get-CimInstance -Query '${query}' | ForEach-Object { $_.ExecutablePath }`,
416 ]);
417 for (const exe of processes.split("\r\n").map((line) => line.trim())) {
418 if (windowsEditors.has(path.win32.basename(exe))) return [exe];
419 }
420 }
421 } catch {
422 // without a process list there is nothing to detect
423 }
424 const fallback = process.env.VISUAL || process.env.EDITOR;
425 return fallback ? splitCommand(fallback) : [];
426}
427
428/** editor name to the shape of the arguments that open a position in it */
429const argStyles = new Map(
430 ( // deno-fmt-ignore
431 "vscode#code,code - insiders,codium,vscodium,cursor,trae,antigravity\n"
432 + "suffix#atom,subl,sublime,sublime_text,wstorm,charm,zed\n"
433 + "jetbrains#appcode,clion,idea,phpstorm,pycharm,rubymine,webstorm,goland,rider\n"
434 + "mate#mate,rmate,mine\n"
435 + "vim#vim,mvim\n"
436 + "plus#joe,gvim\n"
437 + "emacs#emacs,emacsclient\n"
438 + "notepad++#notepad++"
439 ).split("\n").flatMap((group) => {
440 const [style, editors] = group.split("#") as [string, string];
441 return editors.split(",").map((editor) => [editor, style] as const);
442 }),
443);
444
445/** editors that take over the currentEditor they are started in */
446const terminalEditors = new Set("vi vim nvim nano emacs joe helix hx micro kak".split(" "));
447
448/**
449 * the tail of the binary macos names a running editor by, to the command that
450 * opens a file in it. an app that launches itself has no command here, so the
451 * binary that is running is used, wherever it was installed.
452 */
453const macosEditors = new Map(
454 ( // deno-fmt-ignore
455 "/Atom.app/Contents/MacOS/Atom#atom\n"
456 + "/Sublime Text.app/Contents/MacOS/Sublime Text#/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl\n"
457 + "/Sublime Text.app/Contents/MacOS/sublime_text#/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl\n"
458 + "/Visual Studio Code.app/Contents/MacOS/Code#code\n"
459 + "/Visual Studio Code.app/Contents/MacOS/Electron#code\n"
460 + "/Visual Studio Code - Insiders.app/Contents/MacOS/Code - Insiders#code-insiders\n"
461 + "/Visual Studio Code - Insiders.app/Contents/MacOS/Electron#code-insiders\n"
462 + "/VSCodium.app/Contents/MacOS/Electron#codium\n"
463 + "/Cursor.app/Contents/MacOS/Cursor#cursor\n"
464 + "/Trae.app/Contents/MacOS/Electron#trae\n"
465 + "/Antigravity.app/Contents/MacOS/Electron#antigravity\n"
466 + "/Zed.app/Contents/MacOS/zed#zed\n"
467 + "/MacVim.app/Contents/MacOS/MacVim#mvim\n"
468 + "/AppCode.app/Contents/MacOS/appcode\n"
469 + "/CLion.app/Contents/MacOS/clion\n"
470 + "/IntelliJ IDEA.app/Contents/MacOS/idea\n"
471 + "/IntelliJ IDEA Ultimate.app/Contents/MacOS/idea\n"
472 + "/IntelliJ IDEA Community Edition.app/Contents/MacOS/idea\n"
473 + "/PhpStorm.app/Contents/MacOS/phpstorm\n"
474 + "/PyCharm.app/Contents/MacOS/pycharm\n"
475 + "/PyCharm CE.app/Contents/MacOS/pycharm\n"
476 + "/RubyMine.app/Contents/MacOS/rubymine\n"
477 + "/WebStorm.app/Contents/MacOS/webstorm\n"
478 + "/GoLand.app/Contents/MacOS/goland\n"
479 + "/Rider.app/Contents/MacOS/rider"
480 ).split("\n").map((line) => line.split("#") as [string, string | undefined]),
481);
482
483/** process name linux names a running editor by, to the command that opens a file in it */
484const linuxEditors = new Map(
485 ( // deno-fmt-ignore
486 "atom code-insiders code codium vscodium cursor trae antigravity"
487 + " zed emacs gvim vim sublime_text#subl"
488 + " idea phpstorm pycharm rubymine webstorm goland rider"
489 ).split(" ").map((entry) => entry.split("#") as [string, string | undefined]),
490);
491
492/** windows has no command to open a file in an editor, so the binary is run */
493const windowsEditors = new Set(
494 ( // deno-fmt-ignore
495 "Code.exe,Code - Insiders.exe,VSCodium.exe,Cursor.exe,Trae.exe,Antigravity.exe,zed.exe"
496 + ",atom.exe,sublime_text.exe,notepad++.exe"
497 + ",clion.exe,clion64.exe,idea.exe,idea64.exe,phpstorm.exe,phpstorm64.exe,pycharm.exe,pycharm64.exe"
498 + ",rubymine.exe,rubymine64.exe,webstorm.exe,webstorm64.exe,goland.exe,goland64.exe,rider.exe,rider64.exe"
499 ).split(","),
500);
501
248import * as child_process from "node:child_process";502import * as child_process from "node:child_process";
249import * as fs from "node:fs/promises";503import * as fs from "node:fs/promises";
250import * as os from "node:os";504import * as os from "node:os";
251import * as path from "node:path";505import * as path from "node:path";
252import * as process from "node:process";506import * as process from "node:process";
253import * as stream from "node:stream";507import * as stream from "node:stream";
508import * as url from "node:url";
254import { ASSERT } from "./assert.ts";509import { ASSERT } from "./assert.ts";
510import * as exception from "./exception.ts";
255import * as progress from "./progress.ts";511import * as progress from "./progress.ts";
256import * as string from "./string.ts";512import * as string from "./string.ts";
package-lock.json+830-3
...@@ -10,6 +10,7 @@...@@ -10,6 +10,7 @@
10 "@marko/compiler": "5.39.62",10 "@marko/compiler": "5.39.62",
11 "@types/node": "24.x",11 "@types/node": "24.x",
12 "@types/wawoff2": "^1.0.2",12 "@types/wawoff2": "^1.0.2",
13 "cspell-lib": "10.4.0",
13 "dashjs": "5.0.3",14 "dashjs": "5.0.3",
14 "dprint": "0.53.1",15 "dprint": "0.53.1",
15 "drizzle-orm": "1.0.0-rc.4",16 "drizzle-orm": "1.0.0-rc.4",
...@@ -58,6 +59,539 @@...@@ -58,6 +59,539 @@
58 }59 }
59 }60 }
60 },61 },
62 "node_modules/@cspell/cspell-bundled-dicts": {
63 "version": "10.4.0",
64 "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-10.4.0.tgz",
65 "integrity": "sha512-ey23dut7NdnOsQQinQhROFZUh4LvLhJnnZCiq7y+KXMngdqXnIAZuEnnwXk3IfdOX+tycDC8OuMssLXDJ3Fnxw==",
66 "license": "MIT",
67 "dependencies": {
68 "@cspell/dict-ada": "^4.1.1",
69 "@cspell/dict-al": "^1.1.1",
70 "@cspell/dict-aws": "^4.0.17",
71 "@cspell/dict-bash": "^4.2.3",
72 "@cspell/dict-companies": "^3.2.13",
73 "@cspell/dict-cpp": "^7.1.3",
74 "@cspell/dict-cryptocurrencies": "^5.0.5",
75 "@cspell/dict-csharp": "^4.0.8",
76 "@cspell/dict-css": "^4.1.2",
77 "@cspell/dict-dart": "^2.3.2",
78 "@cspell/dict-data-science": "^2.0.16",
79 "@cspell/dict-django": "^4.1.7",
80 "@cspell/dict-docker": "^1.1.17",
81 "@cspell/dict-dotnet": "^5.0.15",
82 "@cspell/dict-elixir": "^4.0.8",
83 "@cspell/dict-en_us": "^4.4.41",
84 "@cspell/dict-en-common-misspellings": "^2.2.5",
85 "@cspell/dict-en-gb-mit": "^3.1.30",
86 "@cspell/dict-filetypes": "^3.0.18",
87 "@cspell/dict-flutter": "^1.1.1",
88 "@cspell/dict-fonts": "^4.0.6",
89 "@cspell/dict-fsharp": "^1.1.1",
90 "@cspell/dict-fullstack": "^3.2.9",
91 "@cspell/dict-gaming-terms": "^1.1.3",
92 "@cspell/dict-git": "^3.1.1",
93 "@cspell/dict-golang": "^6.0.29",
94 "@cspell/dict-google": "^1.0.9",
95 "@cspell/dict-haskell": "^4.0.6",
96 "@cspell/dict-html": "^4.0.16",
97 "@cspell/dict-html-symbol-entities": "^4.0.5",
98 "@cspell/dict-java": "^5.0.12",
99 "@cspell/dict-julia": "^1.1.1",
100 "@cspell/dict-k8s": "^1.0.13",
101 "@cspell/dict-kotlin": "^1.1.1",
102 "@cspell/dict-latex": "^5.1.0",
103 "@cspell/dict-lorem-ipsum": "^4.0.5",
104 "@cspell/dict-lua": "^4.0.8",
105 "@cspell/dict-makefile": "^1.0.5",
106 "@cspell/dict-markdown": "^2.0.18",
107 "@cspell/dict-monkeyc": "^1.1.2",
108 "@cspell/dict-node": "^5.0.9",
109 "@cspell/dict-npm": "^5.2.54",
110 "@cspell/dict-php": "^4.1.3",
111 "@cspell/dict-powershell": "^5.0.17",
112 "@cspell/dict-public-licenses": "^2.0.17",
113 "@cspell/dict-python": "^4.5.2",
114 "@cspell/dict-r": "^2.1.1",
115 "@cspell/dict-ruby": "^5.1.4",
116 "@cspell/dict-rust": "^4.1.2",
117 "@cspell/dict-scala": "^5.0.11",
118 "@cspell/dict-shell": "^1.2.0",
119 "@cspell/dict-software-terms": "^5.4.8",
120 "@cspell/dict-sql": "^2.2.1",
121 "@cspell/dict-svelte": "^1.0.7",
122 "@cspell/dict-swift": "^2.0.6",
123 "@cspell/dict-terraform": "^1.1.4",
124 "@cspell/dict-typescript": "^3.2.3",
125 "@cspell/dict-vue": "^3.0.5",
126 "@cspell/dict-zig": "^1.0.0"
127 },
128 "engines": {
129 "node": ">=22.18.0"
130 }
131 },
132 "node_modules/@cspell/cspell-performance-monitor": {
133 "version": "10.4.0",
134 "resolved": "https://registry.npmjs.org/@cspell/cspell-performance-monitor/-/cspell-performance-monitor-10.4.0.tgz",
135 "integrity": "sha512-0hCqI8pR0waFKKOR3SZ4VtWddx7HZTr0v7MPCPG0IpaqMzzaibrXn9AtFCe+9T1+UDQrM109YKS+yr0c6xVvMQ==",
136 "license": "MIT",
137 "engines": {
138 "node": ">=22.18.0"
139 }
140 },
141 "node_modules/@cspell/cspell-pipe": {
142 "version": "10.4.0",
143 "resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-10.4.0.tgz",
144 "integrity": "sha512-cXHgST9ySpECyiE5ZK7TcwBqrCb3h9X49j/lSDcWNAVp7GkwnPfcdylH+Zq/VfYMlHad24z8CNXd5H91O9DhEA==",
145 "license": "MIT",
146 "engines": {
147 "node": ">=22.18.0"
148 }
149 },
150 "node_modules/@cspell/cspell-resolver": {
151 "version": "10.4.0",
152 "resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-10.4.0.tgz",
153 "integrity": "sha512-uyI9qX8hagtcFtRTElYGXXyVhhnVxPBSf1Ltds9AlU9cktPOFYeTXQOfBR+Dpt9dCruzX29ZJbd3hR1i5yryZg==",
154 "license": "MIT",
155 "dependencies": {
156 "global-directory": "^5.0.0"
157 },
158 "engines": {
159 "node": ">=22.18.0"
160 }
161 },
162 "node_modules/@cspell/cspell-service-bus": {
163 "version": "10.4.0",
164 "resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-10.4.0.tgz",
165 "integrity": "sha512-IZzx+EWADkN5xbFrW+VS+JQ52+w5tWaZNxDgGa+0Mv79hks6wNa2Vcnce+9vGcBJYgA24VcuAfekI38tYzcDtw==",
166 "license": "MIT",
167 "engines": {
168 "node": ">=22.18.0"
169 }
170 },
171 "node_modules/@cspell/cspell-types": {
172 "version": "10.4.0",
173 "resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-10.4.0.tgz",
174 "integrity": "sha512-LTOrZcDSHj5BkeNSvimuV3AMF7gWjxzXUdQ7zYT/h/57/5flHuE6NPKSXS69ty8vJM0WrVh35L5aDxnCPfDzXA==",
175 "license": "MIT",
176 "engines": {
177 "node": ">=22.18.0"
178 }
179 },
180 "node_modules/@cspell/dict-ada": {
181 "version": "4.1.1",
182 "resolved": "https://registry.npmjs.org/@cspell/dict-ada/-/dict-ada-4.1.1.tgz",
183 "integrity": "sha512-E+0YW9RhZod/9Qy2gxfNZiHJjCYFlCdI69br1eviQQWB8yOTJX0JHXLs79kOYhSW0kINPVUdvddEBe6Lu6CjGQ==",
184 "license": "MIT"
185 },
186 "node_modules/@cspell/dict-al": {
187 "version": "1.1.1",
188 "resolved": "https://registry.npmjs.org/@cspell/dict-al/-/dict-al-1.1.1.tgz",
189 "integrity": "sha512-sD8GCaZetgQL4+MaJLXqbzWcRjfKVp8x+px3HuCaaiATAAtvjwUQ5/Iubiqwfd1boIh2Y1/3EgM3TLQ7Q8e0wQ==",
190 "license": "MIT"
191 },
192 "node_modules/@cspell/dict-aws": {
193 "version": "4.0.17",
194 "resolved": "https://registry.npmjs.org/@cspell/dict-aws/-/dict-aws-4.0.17.tgz",
195 "integrity": "sha512-ORcblTWcdlGjIbWrgKF+8CNEBQiLVKdUOFoTn0KPNkAYnFcdPP0muT4892h7H4Xafh3j72wqB4/loQ6Nti9E/w==",
196 "license": "MIT"
197 },
198 "node_modules/@cspell/dict-bash": {
199 "version": "4.2.3",
200 "resolved": "https://registry.npmjs.org/@cspell/dict-bash/-/dict-bash-4.2.3.tgz",
201 "integrity": "sha512-ljUZoKHbDqw5Sx0qpL2qTUlmkmr+vhZH/sCNrNaBZKTbdgiswErSnIF1jRbGmEitJNxHRHWsuZyVgnTGfVO1Yw==",
202 "license": "MIT",
203 "dependencies": {
204 "@cspell/dict-shell": "1.2.0"
205 }
206 },
207 "node_modules/@cspell/dict-companies": {
208 "version": "3.2.13",
209 "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.13.tgz",
210 "integrity": "sha512-mBJeYzCkiiWgNQxKg7zyQOpFPwcXO4IAzXGbOEFfcQ4Q0G5Fa7Kqd8z+gvnvSK7NPWKJaFmiApC6I0cka02qmQ==",
211 "license": "MIT"
212 },
213 "node_modules/@cspell/dict-cpp": {
214 "version": "7.1.3",
215 "resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-7.1.3.tgz",
216 "integrity": "sha512-2d2MSh+C+xzzdqjLwtt40TdXWDR/pK0q1Q9L5zC+1EPSCIZxlN7CHbhlXWpl0JqUSdy2xaWaWucwnoGznMTneA==",
217 "license": "MIT"
218 },
219 "node_modules/@cspell/dict-cryptocurrencies": {
220 "version": "5.0.5",
221 "resolved": "https://registry.npmjs.org/@cspell/dict-cryptocurrencies/-/dict-cryptocurrencies-5.0.5.tgz",
222 "integrity": "sha512-R68hYYF/rtlE6T/dsObStzN5QZw+0aQBinAXuWCVqwdS7YZo0X33vGMfChkHaiCo3Z2+bkegqHlqxZF4TD3rUA==",
223 "license": "MIT"
224 },
225 "node_modules/@cspell/dict-csharp": {
226 "version": "4.0.8",
227 "resolved": "https://registry.npmjs.org/@cspell/dict-csharp/-/dict-csharp-4.0.8.tgz",
228 "integrity": "sha512-qmk45pKFHSxckl5mSlbHxmDitSsGMlk/XzFgt7emeTJWLNSTUK//MbYAkBNRtfzB4uD7pAFiKgpKgtJrTMRnrQ==",
229 "license": "MIT"
230 },
231 "node_modules/@cspell/dict-css": {
232 "version": "4.1.2",
233 "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.1.2.tgz",
234 "integrity": "sha512-+ylGoKdwZ2sVOCOnU2Eq5wDZx+RaVX3HoKyNHGGsFvhSw6IidQ6tH/mAPKBDofViHJoWCPNlklE0lTr6MDG3QA==",
235 "license": "MIT"
236 },
237 "node_modules/@cspell/dict-dart": {
238 "version": "2.3.2",
239 "resolved": "https://registry.npmjs.org/@cspell/dict-dart/-/dict-dart-2.3.2.tgz",
240 "integrity": "sha512-sUiLW56t9gfZcu8iR/5EUg+KYyRD83Cjl3yjDEA2ApVuJvK1HhX+vn4e4k4YfjpUQMag8XO2AaRhARE09+/rqw==",
241 "license": "MIT"
242 },
243 "node_modules/@cspell/dict-data-science": {
244 "version": "2.0.16",
245 "resolved": "https://registry.npmjs.org/@cspell/dict-data-science/-/dict-data-science-2.0.16.tgz",
246 "integrity": "sha512-M72mxv5asuAnORurz4iXRJ+Tw9XBq6eu7D2Ne7biP0Z1RciKGNxXWu9JycA/KlVvK1hAlKj/fANlXhuEWpXKFg==",
247 "license": "MIT"
248 },
249 "node_modules/@cspell/dict-django": {
250 "version": "4.1.7",
251 "resolved": "https://registry.npmjs.org/@cspell/dict-django/-/dict-django-4.1.7.tgz",
252 "integrity": "sha512-94LIxiF/StBTxDkmBhi1eoOX9D/B7gk6pabBvIZFh0gmREAU6ESqU2Wv/RfGARdueMQMPfOJka+vbtL4yKv0oA==",
253 "license": "WTFPL"
254 },
255 "node_modules/@cspell/dict-docker": {
256 "version": "1.1.17",
257 "resolved": "https://registry.npmjs.org/@cspell/dict-docker/-/dict-docker-1.1.17.tgz",
258 "integrity": "sha512-OcnVTIpHIYYKhztNTyK8ShAnXTfnqs43hVH6p0py0wlcwRIXe5uj4f12n7zPf2CeBI7JAlPjEsV0Rlf4hbz/xQ==",
259 "license": "MIT"
260 },
261 "node_modules/@cspell/dict-dotnet": {
262 "version": "5.0.15",
263 "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.15.tgz",
264 "integrity": "sha512-j2MUKPlzMeDy/mnb1EjmtkX8e6VzZdw60Qyf6vPlp7WZhUwwp2LBlKOWx7+hASjP6k4a0nDpmn77G+4upxvbZA==",
265 "license": "MIT"
266 },
267 "node_modules/@cspell/dict-elixir": {
268 "version": "4.0.8",
269 "resolved": "https://registry.npmjs.org/@cspell/dict-elixir/-/dict-elixir-4.0.8.tgz",
270 "integrity": "sha512-CyfphrbMyl4Ms55Vzuj+mNmd693HjBFr9hvU+B2YbFEZprE5AG+EXLYTMRWrXbpds4AuZcvN3deM2XVB80BN/Q==",
271 "license": "MIT"
272 },
273 "node_modules/@cspell/dict-en_us": {
274 "version": "4.4.41",
275 "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.41.tgz",
276 "integrity": "sha512-PsfGqN/dSSPpaSl1vKaPI4DHAmgbFITON398YUEvoduDM1mWXnDOL+qBQ/ZjpWs5+aaShPq5c1vpJg8fuQFOQw==",
277 "license": "MIT"
278 },
279 "node_modules/@cspell/dict-en-common-misspellings": {
280 "version": "2.2.5",
281 "resolved": "https://registry.npmjs.org/@cspell/dict-en-common-misspellings/-/dict-en-common-misspellings-2.2.5.tgz",
282 "integrity": "sha512-c1bj+HXT8fafxrBT/nmVLwr7ILVomgVSGlWsb2wjJYanqvLJHXkCVmr0sM97F9FJeWQFIgwrRi5MWSYbNQAQ6A==",
283 "license": "CC BY-SA 4.0"
284 },
285 "node_modules/@cspell/dict-en-gb-mit": {
286 "version": "3.1.30",
287 "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.30.tgz",
288 "integrity": "sha512-wmsPqPrO+GRfD8iA2jSKzRdEVd46ZbnDttRWM8RC4a8DkeiIanTtIgqX5V23jJibSgpsRVAtY+ktTw2nOK/Yvw==",
289 "license": "MIT"
290 },
291 "node_modules/@cspell/dict-filetypes": {
292 "version": "3.0.18",
293 "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.18.tgz",
294 "integrity": "sha512-yU7RKD/x1IWmDLzWeiItMwgV+6bUcU/af23uS0+uGiFUbsY1qWV/D4rxlAAO6Z7no3J2z8aZOkYIOvUrJq0Rcw==",
295 "license": "MIT"
296 },
297 "node_modules/@cspell/dict-flutter": {
298 "version": "1.1.1",
299 "resolved": "https://registry.npmjs.org/@cspell/dict-flutter/-/dict-flutter-1.1.1.tgz",
300 "integrity": "sha512-UlOzRcH2tNbFhZmHJN48Za/2/MEdRHl2BMkCWZBYs+30b91mWvBfzaN4IJQU7dUZtowKayVIF9FzvLZtZokc5A==",
301 "license": "MIT"
302 },
303 "node_modules/@cspell/dict-fonts": {
304 "version": "4.0.6",
305 "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.6.tgz",
306 "integrity": "sha512-aR/0csY01dNb0A1tw/UmN9rKgHruUxsYsvXu6YlSBJFu60s26SKr/k1o4LavpHTQ+lznlYMqAvuxGkE4Flliqw==",
307 "license": "MIT"
308 },
309 "node_modules/@cspell/dict-fsharp": {
310 "version": "1.1.1",
311 "resolved": "https://registry.npmjs.org/@cspell/dict-fsharp/-/dict-fsharp-1.1.1.tgz",
312 "integrity": "sha512-imhs0u87wEA4/cYjgzS0tAyaJpwG7vwtC8UyMFbwpmtw+/bgss+osNfyqhYRyS/ehVCWL17Ewx2UPkexjKyaBA==",
313 "license": "MIT"
314 },
315 "node_modules/@cspell/dict-fullstack": {
316 "version": "3.2.9",
317 "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.9.tgz",
318 "integrity": "sha512-diZX+usW5aZ4/b2T0QM/H/Wl9aNMbdODa1Jq0ReBr/jazmNeWjd+PyqeVgzd1joEaHY+SAnjrf/i9CwKd2ZtWQ==",
319 "license": "MIT"
320 },
321 "node_modules/@cspell/dict-gaming-terms": {
322 "version": "1.1.3",
323 "resolved": "https://registry.npmjs.org/@cspell/dict-gaming-terms/-/dict-gaming-terms-1.1.3.tgz",
324 "integrity": "sha512-hLGzd/f+KX5B/5YxAXuQi4DYEfu6IKudtNFO/Nima3JLXD+8Xn+d0q6VdaBxkWVhGYlzow9o+dce+S9mNtgpMg==",
325 "license": "MIT"
326 },
327 "node_modules/@cspell/dict-git": {
328 "version": "3.1.1",
329 "resolved": "https://registry.npmjs.org/@cspell/dict-git/-/dict-git-3.1.1.tgz",
330 "integrity": "sha512-XvuGVYj9YEu4EzntqyhH8XlTRyy57C6j8oxkIbUQfR2UYzxZGAP77XHl7qY7eC/40r/haDwnBaccyFR6BRmUCg==",
331 "license": "MIT"
332 },
333 "node_modules/@cspell/dict-golang": {
334 "version": "6.0.29",
335 "resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-6.0.29.tgz",
336 "integrity": "sha512-EZCgfxrr5lPnYQLmSvB6YtpfoDFt2KKxAD4ppq2xGSIgDFXAF7Mv4doiXIFUQaynCBIEfAMl3f/uDfrIUo4fyQ==",
337 "license": "MIT"
338 },
339 "node_modules/@cspell/dict-google": {
340 "version": "1.0.9",
341 "resolved": "https://registry.npmjs.org/@cspell/dict-google/-/dict-google-1.0.9.tgz",
342 "integrity": "sha512-biL65POqialY0i4g6crj7pR6JnBkbsPovB2WDYkj3H4TuC/QXv7Pu5pdPxeUJA6TSCHI7T5twsO4VSVyRxD9CA==",
343 "license": "MIT"
344 },
345 "node_modules/@cspell/dict-haskell": {
346 "version": "4.0.6",
347 "resolved": "https://registry.npmjs.org/@cspell/dict-haskell/-/dict-haskell-4.0.6.tgz",
348 "integrity": "sha512-ib8SA5qgftExpYNjWhpYIgvDsZ/0wvKKxSP+kuSkkak520iPvTJumEpIE+qPcmJQo4NzdKMN8nEfaeci4OcFAQ==",
349 "license": "MIT"
350 },
351 "node_modules/@cspell/dict-html": {
352 "version": "4.0.16",
353 "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.16.tgz",
354 "integrity": "sha512-9B6/Cpb5YVcYgsEAlKudun1yd4ONllYS/ZibKLYea2apPR+l2vQdARnPrP9kTqa7NUNfRKl7m9Fb6k9rjimnow==",
355 "license": "MIT"
356 },
357 "node_modules/@cspell/dict-html-symbol-entities": {
358 "version": "4.0.5",
359 "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.5.tgz",
360 "integrity": "sha512-429alTD4cE0FIwpMucvSN35Ld87HCyuM8mF731KU5Rm4Je2SG6hmVx7nkBsLyrmH3sQukTcr1GaiZsiEg8svPA==",
361 "license": "MIT"
362 },
363 "node_modules/@cspell/dict-java": {
364 "version": "5.0.12",
365 "resolved": "https://registry.npmjs.org/@cspell/dict-java/-/dict-java-5.0.12.tgz",
366 "integrity": "sha512-qPSNhTcl7LGJ5Qp6VN71H8zqvRQK04S08T67knMq9hTA8U7G1sTKzLmBaDOFhq17vNX/+rT+rbRYp+B5Nwza1A==",
367 "license": "MIT"
368 },
369 "node_modules/@cspell/dict-julia": {
370 "version": "1.1.1",
371 "resolved": "https://registry.npmjs.org/@cspell/dict-julia/-/dict-julia-1.1.1.tgz",
372 "integrity": "sha512-WylJR9TQ2cgwd5BWEOfdO3zvDB+L7kYFm0I9u0s9jKHWQ6yKmfKeMjU9oXxTBxIufhCXm92SKwwVNAC7gjv+yA==",
373 "license": "MIT"
374 },
375 "node_modules/@cspell/dict-k8s": {
376 "version": "1.0.13",
377 "resolved": "https://registry.npmjs.org/@cspell/dict-k8s/-/dict-k8s-1.0.13.tgz",
378 "integrity": "sha512-ELGkS13k7K/NEfVimBSrxVTfqXvOF/Kvxj4I62YxRm8bvHbfoXgrGaOx28lPiNRz+dmu+yYtvuXbnURKtYbC6g==",
379 "license": "MIT"
380 },
381 "node_modules/@cspell/dict-kotlin": {
382 "version": "1.1.1",
383 "resolved": "https://registry.npmjs.org/@cspell/dict-kotlin/-/dict-kotlin-1.1.1.tgz",
384 "integrity": "sha512-J3NzzfgmxRvEeOe3qUXnSJQCd38i/dpF9/t3quuWh6gXM+krsAXP75dY1CzDmS8mrJAlBdVBeAW5eAZTD8g86Q==",
385 "license": "MIT"
386 },
387 "node_modules/@cspell/dict-latex": {
388 "version": "5.1.0",
389 "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-5.1.0.tgz",
390 "integrity": "sha512-qxT4guhysyBt0gzoliXYEBYinkAdEtR2M7goRaUH0a7ltCsoqqAeEV8aXYRIdZGcV77gYSobvu3jJL038tlPAw==",
391 "license": "MIT"
392 },
393 "node_modules/@cspell/dict-lorem-ipsum": {
394 "version": "4.0.5",
395 "resolved": "https://registry.npmjs.org/@cspell/dict-lorem-ipsum/-/dict-lorem-ipsum-4.0.5.tgz",
396 "integrity": "sha512-9a4TJYRcPWPBKkQAJ/whCu4uCAEgv/O2xAaZEI0n4y1/l18Yyx8pBKoIX5QuVXjjmKEkK7hi5SxyIsH7pFEK9Q==",
397 "license": "MIT"
398 },
399 "node_modules/@cspell/dict-lua": {
400 "version": "4.0.8",
401 "resolved": "https://registry.npmjs.org/@cspell/dict-lua/-/dict-lua-4.0.8.tgz",
402 "integrity": "sha512-N4PkgNDMu9JVsRu7JBS/3E/dvfItRgk9w5ga2dKq+JupP2Y3lojNaAVFhXISh4Y0a6qXDn2clA6nvnavQ/jjLA==",
403 "license": "MIT"
404 },
405 "node_modules/@cspell/dict-makefile": {
406 "version": "1.0.5",
407 "resolved": "https://registry.npmjs.org/@cspell/dict-makefile/-/dict-makefile-1.0.5.tgz",
408 "integrity": "sha512-4vrVt7bGiK8Rx98tfRbYo42Xo2IstJkAF4tLLDMNQLkQ86msDlYSKG1ZCk8Abg+EdNcFAjNhXIiNO+w4KflGAQ==",
409 "license": "MIT"
410 },
411 "node_modules/@cspell/dict-markdown": {
412 "version": "2.0.18",
413 "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.18.tgz",
414 "integrity": "sha512-KLRSwVrwKDz4n7bl2XQjzvaBZbGgx5QKkP5Ok1b24xaO3TpXsLhAbAn1mItvFlI2tF6Rkt83iYjQcYppywuf5Q==",
415 "license": "MIT",
416 "peerDependencies": {
417 "@cspell/dict-css": "^4.1.2",
418 "@cspell/dict-html": "^4.0.16",
419 "@cspell/dict-html-symbol-entities": "^4.0.5",
420 "@cspell/dict-typescript": "^3.2.3"
421 }
422 },
423 "node_modules/@cspell/dict-monkeyc": {
424 "version": "1.1.2",
425 "resolved": "https://registry.npmjs.org/@cspell/dict-monkeyc/-/dict-monkeyc-1.1.2.tgz",
426 "integrity": "sha512-4VzwdSUVqyUVmAKAzMkVwVa7AkvSi1l1OHdQr58MWq4VR4NQDO8sVQSibd2PMxruXTkxJt9UwVQVHgaaVTiV1Q==",
427 "license": "MIT"
428 },
429 "node_modules/@cspell/dict-node": {
430 "version": "5.0.9",
431 "resolved": "https://registry.npmjs.org/@cspell/dict-node/-/dict-node-5.0.9.tgz",
432 "integrity": "sha512-hO+ga+uYZ/WA4OtiMEyKt5rDUlUyu3nXMf8KVEeqq2msYvAPdldKBGH7lGONg6R/rPhv53Rb+0Y1SLdoK1+7wQ==",
433 "license": "MIT"
434 },
435 "node_modules/@cspell/dict-npm": {
436 "version": "5.2.54",
437 "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.54.tgz",
438 "integrity": "sha512-7jQPRZL1XnZ0HPk5iYAq5WpJ5dpgKHRDBl9aB6uQQBJdo90mCF4bjPGSVl2HrWgx+IXlafiyx/QNO2XII/hgYg==",
439 "license": "MIT"
440 },
441 "node_modules/@cspell/dict-php": {
442 "version": "4.1.3",
443 "resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-4.1.3.tgz",
444 "integrity": "sha512-41oKzl7rLVrrVZA/Gs0ESnP6KliEX7c+IwaaNmOL3PAoHasFDVEmIFeENIzuDdM12UcHT+MlLkAm3Qw/lS/ERA==",
445 "license": "MIT"
446 },
447 "node_modules/@cspell/dict-powershell": {
448 "version": "5.0.17",
449 "resolved": "https://registry.npmjs.org/@cspell/dict-powershell/-/dict-powershell-5.0.17.tgz",
450 "integrity": "sha512-khOoIdBgKRk2scnr7888pf70yJ1Tr3RGdnYcAN0MJ2vtaNRKoOTCFc13gXUkNDHwEyhMI1h9oz3KsT180F2amw==",
451 "license": "MIT"
452 },
453 "node_modules/@cspell/dict-public-licenses": {
454 "version": "2.0.17",
455 "resolved": "https://registry.npmjs.org/@cspell/dict-public-licenses/-/dict-public-licenses-2.0.17.tgz",
456 "integrity": "sha512-Asdumgg3oZ4ki//b2a+beiVIsNZA5thim4t4v3lTDLY/6a8VAPmTjzC3Y2t0x7ywC/bzAhi1KW/fg8JlwHDgig==",
457 "license": "MIT"
458 },
459 "node_modules/@cspell/dict-python": {
460 "version": "4.5.2",
461 "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.5.2.tgz",
462 "integrity": "sha512-CFcTDGN/ycn45WXC5ZZ06pG09WbGKMpPm1rkMuqMP98/jwjw3iiK8c0izWRd8x027n1rOZ9kJ8f9Sd+bQ22HJg==",
463 "license": "MIT",
464 "dependencies": {
465 "@cspell/dict-data-science": "^2.0.16"
466 }
467 },
468 "node_modules/@cspell/dict-r": {
469 "version": "2.1.1",
470 "resolved": "https://registry.npmjs.org/@cspell/dict-r/-/dict-r-2.1.1.tgz",
471 "integrity": "sha512-71Ka+yKfG4ZHEMEmDxc6+blFkeTTvgKbKAbwiwQAuKl3zpqs1Y0vUtwW2N4b3LgmSPhV3ODVY0y4m5ofqDuKMw==",
472 "license": "MIT"
473 },
474 "node_modules/@cspell/dict-ruby": {
475 "version": "5.1.4",
476 "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.1.4.tgz",
477 "integrity": "sha512-Slw1dLY/pW5liJ5Fle+MIPphIdleZkIM6STDoXCN7sy298GfbfT34sJTcSZcXzZJVXiCEAvlLp+s1YNr6DuWNg==",
478 "license": "MIT"
479 },
480 "node_modules/@cspell/dict-rust": {
481 "version": "4.1.2",
482 "resolved": "https://registry.npmjs.org/@cspell/dict-rust/-/dict-rust-4.1.2.tgz",
483 "integrity": "sha512-O1FHrumYcO+HZti3dHfBPUdnDFkI+nbYK3pxYmiM1sr+G0ebOd6qchmswS0Wsc6ZdEVNiPYJY/gZQR6jfW3uOg==",
484 "license": "MIT"
485 },
486 "node_modules/@cspell/dict-scala": {
487 "version": "5.0.11",
488 "resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-5.0.11.tgz",
489 "integrity": "sha512-+D59847Y61KZ0MBoXJzBMtOXfFhezEb6ShjvJlL7bnryyy6iBwAfozbz7OJAe+KrEpYm+GlVV7Wy4FmJSgxBag==",
490 "license": "MIT"
491 },
492 "node_modules/@cspell/dict-shell": {
493 "version": "1.2.0",
494 "resolved": "https://registry.npmjs.org/@cspell/dict-shell/-/dict-shell-1.2.0.tgz",
495 "integrity": "sha512-PVctvT22lJ49niMiakO8xieY7ELCAzjSqhejWR7bAMb5AZ9F4WDEs+XdGMnoVHWeXq7K5rcepLPmEJb+37zzIw==",
496 "license": "MIT"
497 },
498 "node_modules/@cspell/dict-software-terms": {
499 "version": "5.4.8",
500 "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.4.8.tgz",
501 "integrity": "sha512-7Fv/jgRghpcEcvpmrRR54gPp1l85pPLmlMua74hjhMJoWklAp905V742XR6u5HwvGRH7aBTW9euNsJULav1E6A==",
502 "license": "MIT"
503 },
504 "node_modules/@cspell/dict-sql": {
505 "version": "2.2.1",
506 "resolved": "https://registry.npmjs.org/@cspell/dict-sql/-/dict-sql-2.2.1.tgz",
507 "integrity": "sha512-qDHF8MpAYCf4pWU8NKbnVGzkoxMNrFqBHyG/dgrlic5EQiKANCLELYtGlX5auIMDLmTf1inA0eNtv74tyRJ/vg==",
508 "license": "MIT"
509 },
510 "node_modules/@cspell/dict-svelte": {
511 "version": "1.0.7",
512 "resolved": "https://registry.npmjs.org/@cspell/dict-svelte/-/dict-svelte-1.0.7.tgz",
513 "integrity": "sha512-hGZsGqP0WdzKkdpeVLBivRuSNzOTvN036EBmpOwxH+FTY2DuUH7ecW+cSaMwOgmq5JFSdTcbTNFlNC8HN8lhaQ==",
514 "license": "MIT"
515 },
516 "node_modules/@cspell/dict-swift": {
517 "version": "2.0.6",
518 "resolved": "https://registry.npmjs.org/@cspell/dict-swift/-/dict-swift-2.0.6.tgz",
519 "integrity": "sha512-PnpNbrIbex2aqU1kMgwEKvCzgbkHtj3dlFLPMqW1vSniop7YxaDTtvTUO4zA++ugYAEL+UK8vYrBwDPTjjvSnA==",
520 "license": "MIT"
521 },
522 "node_modules/@cspell/dict-terraform": {
523 "version": "1.1.4",
524 "resolved": "https://registry.npmjs.org/@cspell/dict-terraform/-/dict-terraform-1.1.4.tgz",
525 "integrity": "sha512-Ere42ilvMFvQA4GlcN0OKlruMPR6EsvaB+iTHzj2xc+NJGRK64V7yApUcWrOrSgTiM/vhWXPIsK3OMfiAiNdmA==",
526 "license": "MIT"
527 },
528 "node_modules/@cspell/dict-typescript": {
529 "version": "3.2.3",
530 "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz",
531 "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==",
532 "license": "MIT"
533 },
534 "node_modules/@cspell/dict-vue": {
535 "version": "3.0.5",
536 "resolved": "https://registry.npmjs.org/@cspell/dict-vue/-/dict-vue-3.0.5.tgz",
537 "integrity": "sha512-Mqutb8jbM+kIcywuPQCCaK5qQHTdaByoEO2J9LKFy3sqAdiBogNkrplqUK0HyyRFgCfbJUgjz3N85iCMcWH0JA==",
538 "license": "MIT"
539 },
540 "node_modules/@cspell/dict-zig": {
541 "version": "1.0.0",
542 "resolved": "https://registry.npmjs.org/@cspell/dict-zig/-/dict-zig-1.0.0.tgz",
543 "integrity": "sha512-XibBIxBlVosU06+M6uHWkFeT0/pW5WajDRYdXG2CgHnq85b0TI/Ks0FuBJykmsgi2CAD3Qtx8UHFEtl/DSFnAQ==",
544 "license": "MIT"
545 },
546 "node_modules/@cspell/dynamic-import": {
547 "version": "10.4.0",
548 "resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-10.4.0.tgz",
549 "integrity": "sha512-scSwtJDqpRT/Kqzo64XR8OWzXYfdpp9Yneq99Drhqm5gXXI2BAlgjzhBnMGKa+x7eIJZVM2+D2uFeDI2xQYM2w==",
550 "license": "MIT",
551 "dependencies": {
552 "@cspell/url": "10.4.0",
553 "import-meta-resolve": "^4.2.0"
554 },
555 "engines": {
556 "node": ">=22.18.0"
557 }
558 },
559 "node_modules/@cspell/filetypes": {
560 "version": "10.4.0",
561 "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-10.4.0.tgz",
562 "integrity": "sha512-ez0MyFxnpYN9eMkH8vGGseQPsCRwD7ZlsR+ivQ2rPx04CX7ctp015nWv71P7m+T6V05AsyAwyEGZP9vrTlB4Ig==",
563 "license": "MIT",
564 "engines": {
565 "node": ">=22.18.0"
566 }
567 },
568 "node_modules/@cspell/rpc": {
569 "version": "10.4.0",
570 "resolved": "https://registry.npmjs.org/@cspell/rpc/-/rpc-10.4.0.tgz",
571 "integrity": "sha512-tX1YLQYPQz+p73U5OTLmOxIrsmRkpaaKrPnfrVIpK5yv3erWE8ELVZD0zK01rursbL1rW3gyOv0ShHlmoE5VNA==",
572 "license": "MIT",
573 "engines": {
574 "node": ">=22.18.0"
575 }
576 },
577 "node_modules/@cspell/strong-weak-map": {
578 "version": "10.4.0",
579 "resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-10.4.0.tgz",
580 "integrity": "sha512-y8+Hk90mA3UY4HYEWBJokvNcixQOKkRDLEr+yXSYyOJnt6MhAu8D7N567HjEVNj0e7oirXl62wpNglPfo7qAcQ==",
581 "license": "MIT",
582 "engines": {
583 "node": ">=22.18.0"
584 }
585 },
586 "node_modules/@cspell/url": {
587 "version": "10.4.0",
588 "resolved": "https://registry.npmjs.org/@cspell/url/-/url-10.4.0.tgz",
589 "integrity": "sha512-8NvDv8h3eaxejAvD+crMsSEgGq6LiunfS6iD2TBAcDqskNsIpN43AMHNNRf7T7Fd/3Y413nQDBH4RmsHcNsiqg==",
590 "license": "MIT",
591 "engines": {
592 "node": ">=22.18.0"
593 }
594 },
61 "node_modules/@dprint/darwin-arm64": {595 "node_modules/@dprint/darwin-arm64": {
62 "version": "0.53.1",596 "version": "0.53.1",
63 "resolved": "https://registry.npmjs.org/@dprint/darwin-arm64/-/darwin-arm64-0.53.1.tgz",597 "resolved": "https://registry.npmjs.org/@dprint/darwin-arm64/-/darwin-arm64-0.53.1.tgz",
...@@ -1135,6 +1669,12 @@...@@ -1135,6 +1669,12 @@
1135 "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",1669 "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
1136 "license": "Python-2.0"1670 "license": "Python-2.0"
1137 },1671 },
1672 "node_modules/array-timsort": {
1673 "version": "1.0.3",
1674 "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz",
1675 "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==",
1676 "license": "MIT"
1677 },
1138 "node_modules/assertion-error": {1678 "node_modules/assertion-error": {
1139 "version": "1.1.0",1679 "version": "1.1.0",
1140 "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",1680 "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
...@@ -1230,6 +1770,19 @@...@@ -1230,6 +1770,19 @@
1230 "integrity": "sha512-eNk3TRV+xQMJ1PEj0FQGY8KD4m0GPxT487XJ+Iftm7mVa9WpPFDMWqPt+46buiP5j5Wzqe5oMIhqBcAeKfygSA==",1770 "integrity": "sha512-eNk3TRV+xQMJ1PEj0FQGY8KD4m0GPxT487XJ+Iftm7mVa9WpPFDMWqPt+46buiP5j5Wzqe5oMIhqBcAeKfygSA==",
1231 "license": "MIT"1771 "license": "MIT"
1232 },1772 },
1773 "node_modules/comment-json": {
1774 "version": "5.0.0",
1775 "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz",
1776 "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==",
1777 "license": "MIT",
1778 "dependencies": {
1779 "array-timsort": "^1.0.3",
1780 "esprima": "^4.0.1"
1781 },
1782 "engines": {
1783 "node": ">= 6"
1784 }
1785 },
1233 "node_modules/complain": {1786 "node_modules/complain": {
1234 "version": "1.6.1",1787 "version": "1.6.1",
1235 "resolved": "https://registry.npmjs.org/complain/-/complain-1.6.1.tgz",1788 "resolved": "https://registry.npmjs.org/complain/-/complain-1.6.1.tgz",
...@@ -1245,6 +1798,125 @@...@@ -1245,6 +1798,125 @@
1245 "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",1798 "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1246 "license": "MIT"1799 "license": "MIT"
1247 },1800 },
1801 "node_modules/cspell-config-lib": {
1802 "version": "10.4.0",
1803 "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-10.4.0.tgz",
1804 "integrity": "sha512-1P0xmWIlGqwsHf8he9BWNdqV2bvRBkxCv5ctj3oHBraRFS6CPjdJBptyjuM0dgTwxmxPFcuWhTG8miTvFMje5g==",
1805 "license": "MIT",
1806 "dependencies": {
1807 "@cspell/cspell-types": "10.4.0",
1808 "comment-json": "^5.0.0",
1809 "smol-toml": "^1.9.1",
1810 "yaml": "^2.9.1"
1811 },
1812 "engines": {
1813 "node": ">=22.18.0"
1814 }
1815 },
1816 "node_modules/cspell-dictionary": {
1817 "version": "10.4.0",
1818 "resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-10.4.0.tgz",
1819 "integrity": "sha512-FwCQBzqOxjAg+GhymrY288As9DmJqHVrkAr++0tCQ1wWFVw2Nt1W3cCKn9W987Dr+giSuNREdQFpGbQ/cHluTg==",
1820 "license": "MIT",
1821 "dependencies": {
1822 "@cspell/cspell-performance-monitor": "10.4.0",
1823 "@cspell/cspell-pipe": "10.4.0",
1824 "@cspell/cspell-types": "10.4.0",
1825 "cspell-trie-lib": "10.4.0",
1826 "fast-equals": "^6.1.1"
1827 },
1828 "engines": {
1829 "node": ">=22.18.0"
1830 }
1831 },
1832 "node_modules/cspell-glob": {
1833 "version": "10.4.0",
1834 "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-10.4.0.tgz",
1835 "integrity": "sha512-XnctlpRbhln7dTmJyISw7HdsXM38CW+S7Ygm5kkAEixSyceim8RJfRj2rhtofL7B6Ra6YdRmJnm2/PWDOIVzLw==",
1836 "license": "MIT",
1837 "dependencies": {
1838 "@cspell/url": "10.4.0",
1839 "picomatch": "^4.0.7"
1840 },
1841 "engines": {
1842 "node": ">=22.18.0"
1843 }
1844 },
1845 "node_modules/cspell-grammar": {
1846 "version": "10.4.0",
1847 "resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-10.4.0.tgz",
1848 "integrity": "sha512-zWxN9g0oh2tzJDM3C11prpsqF2nTw5AHpXtOslTm3D5d4ZL4BfBHRj5PFxbz3Px4fKz/KptPhxSP5yGDCNtjJg==",
1849 "license": "MIT",
1850 "dependencies": {
1851 "@cspell/cspell-pipe": "10.4.0",
1852 "@cspell/cspell-types": "10.4.0"
1853 },
1854 "bin": {
1855 "cspell-grammar": "bin.mjs"
1856 },
1857 "engines": {
1858 "node": ">=22.18.0"
1859 }
1860 },
1861 "node_modules/cspell-io": {
1862 "version": "10.4.0",
1863 "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-10.4.0.tgz",
1864 "integrity": "sha512-EunMcHZbgA7PYj6172t2nPFVVWo0L7xi4awIhMay27c3vYGiA+ajowIBtIdy2MLOTSqL+XOfcjkuahXh8L8BzQ==",
1865 "license": "MIT",
1866 "dependencies": {
1867 "@cspell/cspell-service-bus": "10.4.0",
1868 "@cspell/url": "10.4.0"
1869 },
1870 "engines": {
1871 "node": ">=22.18.0"
1872 }
1873 },
1874 "node_modules/cspell-lib": {
1875 "version": "10.4.0",
1876 "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-10.4.0.tgz",
1877 "integrity": "sha512-wfqmn19pfEUjWy7UJ4xPeRwyb/LFXHxFVJ6maX87OLRuSE9fTP+cr74VANpKycd/rieuB2ffKta7l38n+Xhj/Q==",
1878 "license": "MIT",
1879 "dependencies": {
1880 "@cspell/cspell-bundled-dicts": "10.4.0",
1881 "@cspell/cspell-performance-monitor": "10.4.0",
1882 "@cspell/cspell-pipe": "10.4.0",
1883 "@cspell/cspell-resolver": "10.4.0",
1884 "@cspell/cspell-types": "10.4.0",
1885 "@cspell/dynamic-import": "10.4.0",
1886 "@cspell/filetypes": "10.4.0",
1887 "@cspell/rpc": "10.4.0",
1888 "@cspell/strong-weak-map": "10.4.0",
1889 "@cspell/url": "10.4.0",
1890 "cspell-config-lib": "10.4.0",
1891 "cspell-dictionary": "10.4.0",
1892 "cspell-glob": "10.4.0",
1893 "cspell-grammar": "10.4.0",
1894 "cspell-io": "10.4.0",
1895 "cspell-trie-lib": "10.4.0",
1896 "env-paths": "^4.0.0",
1897 "gensequence": "^8.0.8",
1898 "import-fresh": "^4.0.1",
1899 "resolve-from": "^5.0.0",
1900 "vscode-languageserver-textdocument": "^1.0.15",
1901 "vscode-uri": "^3.2.0",
1902 "xdg-basedir": "^5.1.0"
1903 },
1904 "engines": {
1905 "node": ">=22.18.0"
1906 }
1907 },
1908 "node_modules/cspell-trie-lib": {
1909 "version": "10.4.0",
1910 "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-10.4.0.tgz",
1911 "integrity": "sha512-3APfmYOrYEcoiDjupm3JsxLWvqa6IbJ76CqUTuIgXQH2EK0nOJ5SRKSTNP6AplDtalCs0o6VllbiRX6S81g8yw==",
1912 "license": "MIT",
1913 "engines": {
1914 "node": ">=22.18.0"
1915 },
1916 "peerDependencies": {
1917 "@cspell/cspell-types": "10.4.0"
1918 }
1919 },
1248 "node_modules/csstype": {1920 "node_modules/csstype": {
1249 "version": "3.2.3",1921 "version": "3.2.3",
1250 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",1922 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
...@@ -1520,6 +2192,21 @@...@@ -1520,6 +2192,21 @@
1520 }2192 }
1521 }2193 }
1522 },2194 },
2195 "node_modules/env-paths": {
2196 "version": "4.0.0",
2197 "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz",
2198 "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==",
2199 "license": "MIT",
2200 "dependencies": {
2201 "is-safe-filename": "^0.1.0"
2202 },
2203 "engines": {
2204 "node": ">=20"
2205 },
2206 "funding": {
2207 "url": "https://github.com/sponsors/sindresorhus"
2208 }
2209 },
1523 "node_modules/error-stack-parser": {2210 "node_modules/error-stack-parser": {
1524 "version": "2.1.4",2211 "version": "2.1.4",
1525 "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",2212 "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
...@@ -1575,6 +2262,19 @@...@@ -1575,6 +2262,19 @@
1575 "@esbuild/win32-x64": "0.25.5"2262 "@esbuild/win32-x64": "0.25.5"
1576 }2263 }
1577 },2264 },
2265 "node_modules/esprima": {
2266 "version": "4.0.1",
2267 "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
2268 "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
2269 "license": "BSD-2-Clause",
2270 "bin": {
2271 "esparse": "bin/esparse.js",
2272 "esvalidate": "bin/esvalidate.js"
2273 },
2274 "engines": {
2275 "node": ">=4"
2276 }
2277 },
1578 "node_modules/estree-walker": {2278 "node_modules/estree-walker": {
1579 "version": "3.0.3",2279 "version": "3.0.3",
1580 "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",2280 "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
...@@ -1608,6 +2308,15 @@...@@ -1608,6 +2308,15 @@
1608 "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",2308 "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
1609 "license": "MIT"2309 "license": "MIT"
1610 },2310 },
2311 "node_modules/fast-equals": {
2312 "version": "6.2.1",
2313 "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.2.1.tgz",
2314 "integrity": "sha512-s7KFkLPAAbIDGTh+nVYhjTiwKGvgMXAUG5vwzg8vQKj7NSRZ4eEGExBQ4rq9V90fTYVWd1cb/Wr4ffCNBmLQMQ==",
2315 "license": "MIT",
2316 "engines": {
2317 "node": ">=6.0.0"
2318 }
2319 },
1611 "node_modules/fastest-levenshtein": {2320 "node_modules/fastest-levenshtein": {
1612 "version": "1.0.16",2321 "version": "1.0.16",
1613 "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",2322 "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
...@@ -1648,6 +2357,30 @@...@@ -1648,6 +2357,30 @@
1648 "node": "^8.16.0 || ^10.6.0 || >=11.0.0"2357 "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1649 }2358 }
1650 },2359 },
2360 "node_modules/gensequence": {
2361 "version": "8.0.8",
2362 "resolved": "https://registry.npmjs.org/gensequence/-/gensequence-8.0.8.tgz",
2363 "integrity": "sha512-omMVniXEXpdx/vKxGnPRoO2394Otlze28TyxECbFVyoSpZ9H3EO7lemjcB12OpQJzRW4e5tt/dL1rOxry6aMHg==",
2364 "license": "MIT",
2365 "engines": {
2366 "node": ">=20"
2367 }
2368 },
2369 "node_modules/global-directory": {
2370 "version": "5.0.0",
2371 "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz",
2372 "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==",
2373 "license": "MIT",
2374 "dependencies": {
2375 "ini": "6.0.0"
2376 },
2377 "engines": {
2378 "node": ">=20"
2379 },
2380 "funding": {
2381 "url": "https://github.com/sponsors/sindresorhus"
2382 }
2383 },
1651 "node_modules/harfbuzzjs": {2384 "node_modules/harfbuzzjs": {
1652 "version": "0.10.1",2385 "version": "0.10.1",
1653 "resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-0.10.1.tgz",2386 "resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-0.10.1.tgz",
...@@ -1706,6 +2439,28 @@...@@ -1706,6 +2439,28 @@
1706 "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",2439 "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
1707 "license": "MIT"2440 "license": "MIT"
1708 },2441 },
2442 "node_modules/import-fresh": {
2443 "version": "4.0.1",
2444 "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-4.0.1.tgz",
2445 "integrity": "sha512-/XFeXYgkuWgbtqPC7qPKvciXTvhtz7WMNQKQsAM1Kz+j0I5OWJDrybfiya6QtHF9oEvPkLwTHmC9+4OR/MmfIA==",
2446 "license": "MIT",
2447 "engines": {
2448 "node": ">=22.15"
2449 },
2450 "funding": {
2451 "url": "https://github.com/sponsors/sindresorhus"
2452 }
2453 },
2454 "node_modules/import-meta-resolve": {
2455 "version": "4.2.0",
2456 "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
2457 "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
2458 "license": "MIT",
2459 "funding": {
2460 "type": "github",
2461 "url": "https://github.com/sponsors/wooorm"
2462 }
2463 },
1709 "node_modules/imsc": {2464 "node_modules/imsc": {
1710 "version": "1.1.5",2465 "version": "1.1.5",
1711 "resolved": "https://registry.npmjs.org/imsc/-/imsc-1.1.5.tgz",2466 "resolved": "https://registry.npmjs.org/imsc/-/imsc-1.1.5.tgz",
...@@ -1715,6 +2470,15 @@...@@ -1715,6 +2470,15 @@
1715 "sax": "1.2.1"2470 "sax": "1.2.1"
1716 }2471 }
1717 },2472 },
2473 "node_modules/ini": {
2474 "version": "6.0.0",
2475 "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
2476 "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
2477 "license": "ISC",
2478 "engines": {
2479 "node": "^20.17.0 || >=22.9.0"
2480 }
2481 },
1718 "node_modules/is-alphabetical": {2482 "node_modules/is-alphabetical": {
1719 "version": "2.0.1",2483 "version": "2.0.1",
1720 "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",2484 "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
...@@ -1749,6 +2513,18 @@...@@ -1749,6 +2513,18 @@
1749 "url": "https://github.com/sponsors/wooorm"2513 "url": "https://github.com/sponsors/wooorm"
1750 }2514 }
1751 },2515 },
2516 "node_modules/is-safe-filename": {
2517 "version": "0.1.1",
2518 "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz",
2519 "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==",
2520 "license": "MIT",
2521 "engines": {
2522 "node": ">=20"
2523 },
2524 "funding": {
2525 "url": "https://github.com/sponsors/sindresorhus"
2526 }
2527 },
1752 "node_modules/jsesc": {2528 "node_modules/jsesc": {
1753 "version": "3.1.0",2529 "version": "3.1.0",
1754 "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",2530 "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
...@@ -2161,9 +2937,9 @@...@@ -2161,9 +2937,9 @@
2161 "license": "ISC"2937 "license": "ISC"
2162 },2938 },
2163 "node_modules/picomatch": {2939 "node_modules/picomatch": {
2164 "version": "4.0.4",2940 "version": "4.0.7",
2165 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",2941 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
2166 "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",2942 "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
2167 "license": "MIT",2943 "license": "MIT",
2168 "engines": {2944 "engines": {
2169 "node": ">=12"2945 "node": ">=12"
...@@ -2341,6 +3117,18 @@...@@ -2341,6 +3117,18 @@
2341 "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",3117 "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
2342 "license": "ISC"3118 "license": "ISC"
2343 },3119 },
3120 "node_modules/smol-toml": {
3121 "version": "1.9.1",
3122 "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz",
3123 "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==",
3124 "license": "BSD-3-Clause",
3125 "engines": {
3126 "node": ">= 18"
3127 },
3128 "funding": {
3129 "url": "https://github.com/sponsors/cyyynthia"
3130 }
3131 },
2344 "node_modules/source-map": {3132 "node_modules/source-map": {
2345 "version": "0.6.1",3133 "version": "0.6.1",
2346 "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",3134 "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
...@@ -2695,6 +3483,12 @@...@@ -2695,6 +3483,12 @@
2695 }3483 }
2696 }3484 }
2697 },3485 },
3486 "node_modules/vscode-languageserver-textdocument": {
3487 "version": "1.0.15",
3488 "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.15.tgz",
3489 "integrity": "sha512-Gsfr9S8LU3S6885cS+NaleasJ0n3V5y/SOTqHCifQiXWFw3G9w9/YItDfwHrlCz+dSZWWZ8ilYVMcJe7Sz7Sxw==",
3490 "license": "MIT"
3491 },
2698 "node_modules/vscode-oniguruma": {3492 "node_modules/vscode-oniguruma": {
2699 "version": "2.0.1",3493 "version": "2.0.1",
2700 "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz",3494 "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz",
...@@ -2707,6 +3501,12 @@...@@ -2707,6 +3501,12 @@
2707 "integrity": "sha512-rkvG4SraZQaPSN/5XjwKswdU0OP9MF28QjrYzUBbhb8QyG3ljB1Ky996m++jiI7KdiAP2CkBiQZd9pqEDTClqA==",3501 "integrity": "sha512-rkvG4SraZQaPSN/5XjwKswdU0OP9MF28QjrYzUBbhb8QyG3ljB1Ky996m++jiI7KdiAP2CkBiQZd9pqEDTClqA==",
2708 "license": "MIT"3502 "license": "MIT"
2709 },3503 },
3504 "node_modules/vscode-uri": {
3505 "version": "3.2.0",
3506 "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz",
3507 "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==",
3508 "license": "MIT"
3509 },
2710 "node_modules/warp10": {3510 "node_modules/warp10": {
2711 "version": "2.1.0",3511 "version": "2.1.0",
2712 "resolved": "https://registry.npmjs.org/warp10/-/warp10-2.1.0.tgz",3512 "resolved": "https://registry.npmjs.org/warp10/-/warp10-2.1.0.tgz",
...@@ -2741,6 +3541,33 @@...@@ -2741,6 +3541,33 @@
2741 "engines": {3541 "engines": {
2742 "node": ">=8"3542 "node": ">=8"
2743 }3543 }
3544 },
3545 "node_modules/xdg-basedir": {
3546 "version": "5.1.0",
3547 "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz",
3548 "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==",
3549 "license": "MIT",
3550 "engines": {
3551 "node": ">=12"
3552 },
3553 "funding": {
3554 "url": "https://github.com/sponsors/sindresorhus"
3555 }
3556 },
3557 "node_modules/yaml": {
3558 "version": "2.9.1",
3559 "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
3560 "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
3561 "license": "ISC",
3562 "bin": {
3563 "yaml": "bin.mjs"
3564 },
3565 "engines": {
3566 "node": ">= 14.6"
3567 },
3568 "funding": {
3569 "url": "https://github.com/sponsors/eemeli"
3570 }
2744 }3571 }
2745 }3572 }
2746}3573}
package.json+1
...@@ -6,6 +6,7 @@...@@ -6,6 +6,7 @@
6 "@marko/compiler": "5.39.62",6 "@marko/compiler": "5.39.62",
7 "@types/node": "24.x",7 "@types/node": "24.x",
8 "@types/wawoff2": "^1.0.2",8 "@types/wawoff2": "^1.0.2",
9 "cspell-lib": "10.4.0",
9 "dashjs": "5.0.3",10 "dashjs": "5.0.3",
10 "dprint": "0.53.1",11 "dprint": "0.53.1",
11 "drizzle-orm": "1.0.0-rc.4",12 "drizzle-orm": "1.0.0-rc.4",
readme.md+4
...@@ -33,6 +33,10 @@ minimum system requirements:...@@ -33,6 +33,10 @@ minimum system requirements:
3333
34my primary development machine is a Dell Inspiron 7348 (Core i7) with Windows 734my primary development machine is a Dell Inspiron 7348 (Core i7) with Windows 7
3535
36 # copy the site's databases. without `CLOVER_SOT_KEY` in `.env`, the
37 # copies hold what the site shows anyone.
38 node run download-db
39
36 # setup and run the site locally, watching for changes.40 # setup and run the site locally, watching for changes.
37 node run41 node run
3842
src/backend.ts+2
...@@ -13,6 +13,7 @@ app.route("", blog.app);...@@ -13,6 +13,7 @@ app.route("", blog.app);
13app.route("", qa.app);13app.route("", qa.app);
14app.route("", fileViewer.app);14app.route("", fileViewer.app);
15app.route("", friendAuth.app);15app.route("", friendAuth.app);
16app.route("", databases.app);
16app.route("", extra.app);17app.route("", extra.app);
1718
18// Asset middleware has least precedence19// Asset middleware has least precedence
...@@ -67,6 +68,7 @@ import { trimTrailingSlash } from "hono/trailing-slash";...@@ -67,6 +68,7 @@ import { trimTrailingSlash } from "hono/trailing-slash";
67import * as admin from "./admin.ts";68import * as admin from "./admin.ts";
68import * as extra from "./backend-extra.ts";69import * as extra from "./backend-extra.ts";
69import * as blog from "./blog/backend.ts";70import * as blog from "./blog/backend.ts";
71import * as databases from "./databases.ts";
70import * as fileViewer from "./file-viewer/backend.ts";72import * as fileViewer from "./file-viewer/backend.ts";
71import "./file-viewer/sync.ts";73import "./file-viewer/sync.ts";
72import * as friendAuth from "./friend-auth.ts";74import * as friendAuth from "./friend-auth.ts";
src/bin/deploy.ts+3-25
...@@ -16,8 +16,8 @@ export async function main(graph: incremental.Graph) {...@@ -16,8 +16,8 @@ export async function main(graph: incremental.Graph) {
16 `${remote} runs node.js ${stdout.trim()}, and this build's cache is for ${process.version}`,16 `${remote} runs node.js ${stdout.trim()}, and this build's cache is for ${process.version}`,
17 );17 );
1818
19 await download("cache.sqlite");19 // a site that is down can't hand out its databases, and a deploy may be what brings it back
20 await download("questions.sqlite");20 await downloadDb.main().catch((error) => console.warn("building with the local databases:", error));
2121
22 // any error stops the deploy, including the ones a build can still be served with22 // any error stops the deploy, including the ones a build can still be served with
23 await graph.incrementalBuild({ progress, buildAndWriteOutput });23 await graph.incrementalBuild({ progress, buildAndWriteOutput });
...@@ -67,33 +67,11 @@ export async function main(graph: incremental.Graph) {...@@ -67,33 +67,11 @@ export async function main(graph: incremental.Graph) {
67 ], { stdin: "inherit" }).wait();67 ], { stdin: "inherit" }).wait();
68}68}
6969
70/**
71 * replace the local copy of one of the server's databases. the server has it
72 * open, with its latest writes in a write-ahead log that a copy of the file
73 * alone would miss, so the server writes a consistent copy first.
74 */
75async function download(name: string) {
76 const snapshot = `/tmp/deploy-${name}`;
77 const vacuum = `new (require("node:sqlite").DatabaseSync)(".clover/${name}", { readOnly: true })`
78 + `.prepare("vacuum into ?").run("${snapshot}")`;
79 await subprocess.execute(["ssh", remote, `rm -f ${snapshot} && cd paperclover.net && ${node}/node -e '${vacuum}'`]);
80 await rsync.spawn({
81 cwd,
82 args: ["-a", `${remote}:${snapshot}`, `.clover/${name}.next`],
83 progress: progress.start(`download ${name}`),
84 });
85 // the server's /tmp is memory
86 await subprocess.execute(["ssh", remote, `rm ${snapshot}`]);
87 // a write-ahead log beside the old copy would be applied to the new one
88 for (const log of ["-wal", "-shm"]) fs.rmSync(`.clover/${name}${log}`, { force: true });
89 fs.renameSync(`.clover/${name}.next`, `.clover/${name}`);
90}
91
92import { Path } from "#sitegen/path";70import { Path } from "#sitegen/path";
93import { ASSERT } from "@clo/lib/assert";71import { ASSERT } from "@clo/lib/assert";
94import type * as incremental from "@clo/lib/incremental";72import type * as incremental from "@clo/lib/incremental";
95import * as progress from "@clo/lib/progress";73import * as progress from "@clo/lib/progress";
96import * as subprocess from "@clo/lib/subprocess";74import * as subprocess from "@clo/lib/subprocess";
97import * as fs from "node:fs";
98import * as rsync from "../file-viewer/rsync.ts";75import * as rsync from "../file-viewer/rsync.ts";
99import { buildAndWriteOutput } from "../paperclover.net.ts";76import { buildAndWriteOutput } from "../paperclover.net.ts";
77import * as downloadDb from "./download-db.ts";
src/bin/download-db.ts+23-17
...@@ -1,21 +1,27 @@...@@ -1,21 +1,27 @@
1// Pull production state for local development. cache.sqlite comes from the1// Saves copies of the site's databases into `.clover`, which is all that a
2// source of truth's /db route (no ssh needed); questions.sqlite is still2// clone of this repository lacks to build the site. With CLOVER_SOT_KEY, the
3// rsync'd off the web node since the source of truth does not own it yet.3// copies are whole; without it, they hold what the site shows anyone.
4const site = "https://paperclover.net";
5const token = process.env.CLOVER_SOT_KEY ?? null;
6
4export async function main() {7export async function main() {
5 await sync.revalidate();8 for (const name of ["cache.sqlite", "questions.sqlite"]) {
6 await rsync.spawn({9 using node = progress.start(`download ${name}`, { units: "bytes" });
7 cwd: Path.resolve("."),10 const response = await fetch(`${site}/db/${name}`, { headers: token ? { Authorization: token } : {} });
8 args: [11 if (!response.ok || !response.body) {
9 "-a",12 throw new Error(`${site}/db/${name} responded ${response.status} ${response.statusText}`);
10 "--progress",13 }
11 "clo@paperclover.net:~/paperclover.net/.clover/questions.sqlite",14 node.total = Number(response.headers.get("Content-Length"));
12 ".clover/questions.sqlite",15 await getDatabase(name).restore(response.body.pipeThrough(
13 ],16 new TransformStream({
14 progress: progress.start("download questions state"),17 transform(chunk, controller) {
15 });18 node.value += chunk.byteLength;
19 controller.enqueue(chunk);
20 },
21 }),
22 ));
23 }
16}24}
1725
18import { Path } from "#sitegen/path";26import { getDatabase } from "#sitegen/drizzle";
19import * as progress from "@clo/lib/progress";27import * as progress from "@clo/lib/progress";
20import * as rsync from "../file-viewer/rsync.ts";
21import * as sync from "../file-viewer/sync.ts";
src/blog/drafts/webdev/marko-intro.markodown+1-1
...@@ -184,7 +184,7 @@ true but it's close enough for the example)...@@ -184,7 +184,7 @@ true but it's close enough for the example)
184184
185All of the above is about how Marko's syntax works, and how it performs HTML185All of the above is about how Marko's syntax works, and how it performs HTML
186generation with components. Marko also allows interactive components, but an186generation with components. Marko also allows interactive components, but an
187explaination of that is beyond the scope of this page, mostly since I have not187explanation of that is beyond the scope of this page, mostly since I have not
188used it. A brief example of it, modified from their documentation.188used it. A brief example of it, modified from their documentation.
189189
190```marko190```marko
src/blog/localization.ts+2
...@@ -15,6 +15,7 @@ export const languages: LanguageMap = {...@@ -15,6 +15,7 @@ export const languages: LanguageMap = {
15 translatedBy: ({ author }) => `translated by ${author}`,15 translatedBy: ({ author }) => `translated by ${author}`,
16 tableOfContents: "Contents",16 tableOfContents: "Contents",
17 },17 },
18 // cspell:disable
18 ko: {19 ko: {
19 title: "한국어",20 title: "한국어",
20 return: "홈 페이지로 돌아가기",21 return: "홈 페이지로 돌아가기",
...@@ -29,4 +30,5 @@ export const languages: LanguageMap = {...@@ -29,4 +30,5 @@ export const languages: LanguageMap = {
29 translatedBy: ({ author }) => `traducido por ${author}`,30 translatedBy: ({ author }) => `traducido por ${author}`,
30 tableOfContents: "Indice",31 tableOfContents: "Indice",
31 },32 },
33 // cspell:enable
32};34};
src/blog/pages/webdev/one-year-next-app-router/en.mdo+2-2
...@@ -37,7 +37,7 @@ database. Client components are the existing...@@ -37,7 +37,7 @@ database. Client components are the existing
37model, where there is code on the backend to generate HTML text and frontend37model, where there is code on the backend to generate HTML text and frontend
38code to manage the DOM using `window.document.*`.38code to manage the DOM using `window.document.*`.
3939
40> The first disaster: naming!! React is now using the wors40> The first disaster: naming!! React is now using the words
41> <b.server>"server"</b> and <b.client>"client"</b> to refer to41> <b.server>"server"</b> and <b.client>"client"</b> to refer to
42> a very specific things, ignoring their existing definitions. This would be42> a very specific things, ignoring their existing definitions. This would be
43> fine, except <b.client>Client</b> components can run on the backend43> fine, except <b.client>Client</b> components can run on the backend
...@@ -570,7 +570,7 @@ deployment. TanStack slowly took over the codebase, and we eventually deleted...@@ -570,7 +570,7 @@ deployment. TanStack slowly took over the codebase, and we eventually deleted
570all of the Next.js stubs and gained all of the beautiful [type-safety features]570all of the Next.js stubs and gained all of the beautiful [type-safety features]
571that the TanStack Router provides. At the end, the site performed faster from571that the TanStack Router provides. At the end, the site performed faster from
572every angle: Development Mode, Production page load times, Soft navigations,572every angle: Development Mode, Production page load times, Soft navigations,
573and at a lower price than our Next depoyment with Vercel.573and at a lower price than our Next deployment with Vercel.
574574
575[type-safety features]: https://tanstack.com/router/v1/docs/framework/react/guide/type-safety575[type-safety features]: https://tanstack.com/router/v1/docs/framework/react/guide/type-safety
576576
src/cspell.ts created+106
...@@ -0,0 +1,106 @@
1export interface SpellRange {
2 offset: number;
3 length: number;
4 suggestions: string[];
5}
6
7export type Settings = Record<string, ts.JsonValue> & { files: string[]; ignorePaths: string[] };
8
9/** CSpell annotates every unknown word; `unknownWords` decides which of them count. */
10export async function spellCheck(uri: string, text: string, settings: ts.DeepReadonly<Settings>, config: URL) {
11 const result = await cspellLib.spellCheckDocument(
12 { uri, text },
13 { noConfigSearch: true, generateSuggestions: false },
14 { url: config, settings: structuredClone(settings) as Settings, virtual: true },
15 );
16 for (const error of result.errors ?? []) throw error;
17 for (const error of result.configErrors ?? []) throw error.error;
18 for (const errors of result.dictionaryErrors?.values() ?? []) {
19 for (const error of errors) throw error;
20 }
21 if (!result.checked) throw new Error(`CSpell did not check ${uri}.`);
22 const { settingsUsed } = result;
23 const issues = result.issues.filter((issue) => {
24 if (issue.isFlagged || issue.issueType) return true;
25 switch (settingsUsed.unknownWords) {
26 case "report-common-typos":
27 return issue.hasPreferredSuggestions;
28 case "report-simple":
29 return issue.hasPreferredSuggestions || issue.hasSimpleSuggestions;
30 case "report-flagged":
31 return false;
32 default:
33 return true;
34 }
35 });
36 return { issues, settingsUsed };
37}
38
39/** Replacements per unknown word, skipping anything the dictionaries forbid. */
40export async function suggestFixes({ issues, settingsUsed }: Awaited<ReturnType<typeof spellCheck>>) {
41 const dictionary = await cspellLib.getDictionary(settingsUsed);
42 const options = {
43 includeDefaultConfig: false,
44 includeTies: false,
45 strict: settingsUsed.caseSensitive ?? false,
46 numSuggestions: settingsUsed.numSuggestions,
47 numChanges: settingsUsed.suggestionNumChanges,
48 };
49 const canSuggest = (word: string) =>
50 !dictionary.isForbidden(word) && !dictionary.isNoSuggestWord(word, { ignoreCase: !options.strict });
51 const byWord = new Map<string, string[]>();
52 for (const issue of issues) {
53 if (byWord.has(issue.text)) continue;
54 let candidates = (issue.suggestionsEx ?? []).filter((suggestion) =>
55 suggestion.isPreferred
56 && canSuggest(suggestion.word) && canSuggest(suggestion.wordAdjustedToMatchCase ?? suggestion.word)
57 );
58 if (!candidates.length && options.numSuggestions !== 0) {
59 // The word API applies dictionary edit weights that document suggestions omit.
60 const suggested = await cspellLib.suggestionsForWord(issue.text, options, settingsUsed);
61 candidates = suggested.suggestions.filter((suggestion) =>
62 !suggestion.forbidden && !suggestion.noSuggest
63 && canSuggest(suggestion.wordAdjustedToMatchCase ?? suggestion.word)
64 );
65 }
66 const preferred = candidates.filter((suggestion) => suggestion.isPreferred);
67 byWord.set(
68 issue.text,
69 [
70 ...new Set(
71 (preferred.length ? preferred : candidates).map((suggestion) =>
72 suggestion.wordAdjustedToMatchCase ?? suggestion.word
73 ),
74 ),
75 ].slice(0, options.numSuggestions),
76 );
77 }
78 return byWord;
79}
80
81let loaded: Promise<{ settings: Settings; config: URL }> | undefined;
82
83/** Live editor feedback, flagging exactly what the build-time check would. */
84export async function checkText(text: string): Promise<SpellRange[]> {
85 loaded ??= (async () => {
86 const config = url.pathToFileURL(path.resolve("cspell.json"));
87 return { settings: await fs.readFile(config, "utf8").then(JSON.parse) as Settings, config };
88 })();
89 const { settings, config } = await loaded;
90 const result = await spellCheck("file:///question.md", text, settings, config);
91 // A `q:` line quotes the asker verbatim, so only the answer is clover's to spell.
92 const issues = result.issues.filter((issue) => !text.startsWith("q:", text.lastIndexOf("\n", issue.offset) + 1));
93 if (!issues.length) return [];
94 const byWord = await suggestFixes({ ...result, issues });
95 return issues.map((issue) => ({
96 offset: issue.offset,
97 length: issue.text.length,
98 suggestions: byWord.get(issue.text) ?? [],
99 }));
100}
101
102import type * as ts from "@clo/lib/ts";
103import * as cspellLib from "cspell-lib";
104import * as fs from "node:fs/promises";
105import * as path from "node:path";
106import * as url from "node:url";
src/databases.test.ts created+104
...@@ -0,0 +1,104 @@
1const dir = vi.hoisted(() => {
2 const fs = process.getBuiltinModule("node:fs");
3 const os = process.getBuiltinModule("node:os");
4 const dir = fs.mkdtempSync(os.tmpdir() + "/clover-databases-test-");
5 Object.assign(process.env, {
6 CLOVER_DB: dir + "/db",
7 CLOVER_FILE_DERIVED: dir + "/derived",
8 CLOVER_SOT_KEY: "test",
9 });
10 return dir;
11});
12afterAll(() => fs.rmSync(dir, { recursive: true, force: true }));
13
14for (const path of ["/2025/song.mp3", "/2025/.date", "/_unlisted/font.ttf", "/friends/2025/photo.jpg"]) {
15 const file = mediaFiles.createFile({
16 path,
17 date: new Date(0),
18 hash: crypto.createHash("sha1").update(path).digest("hex"),
19 size: 0,
20 duration: 0,
21 dimensions: "",
22 contents: path === "/2025/song.mp3" ? "" : "PRIVATE " + path,
23 });
24 const root = mediaFiles.db.get<{ id: number }>(
25 sql`insert into derived_roots (date, key) values (0, ${file.hash + "/opus"}) returning id`,
26 ).id;
27 mediaFiles.db.run(sql`insert into derived_files (file, root, size, hash) values ('a.webm', ${root}, 0, '')`);
28 mediaFiles.db.run(sql`insert into derived_refs (file, root) values (${file.id}, ${root})`);
29}
30permissions.set("/friends", 1);
31questions.create("normal", "q: public");
32questions.create("reject", "q: PRIVATE rejected");
33questions.create("pending", JSON.stringify({ prompt: "PRIVATE pending", sourceName: "fox" }));
34// a table no copy lists, like the search index questions once had
35getDatabase("questions.sqlite").run(sql`create virtual table leftover using fts5(text)`);
36getDatabase("questions.sqlite").run(sql`insert into leftover values ('PRIVATE rejected')`);
37
38async function download(name: string, headers?: Record<string, string>) {
39 const response = await app.fetch(new Request(`http://localhost/db/${name}`, { headers }));
40 assert.equal(response.status, 200);
41 const bytes = Buffer.from(await response.arrayBuffer());
42 const file = path.join(dir, `${name}.${crypto.randomUUID()}`);
43 fs.writeFileSync(file, bytes);
44 const db = new DatabaseSync(file, { readOnly: true });
45 const column = (query: string) => db.prepare(query).all().map((row) => Object.values(row)[0]);
46 return { bytes, column };
47}
48
49test("a public copy has only what listings show anyone, and nothing else in its bytes", async () => {
50 const cache = await download("cache.sqlite");
51 assert.ok(!cache.bytes.includes("PRIVATE") && !cache.bytes.includes("friends"));
52 assert.deepEqual(cache.column("select path from media_files order by path"), ["/", "/2025", "/2025/song.mp3"]);
53 assert.deepEqual(cache.column("select count(*) from permissions"), [0]);
54 assert.deepEqual(cache.column("select count(*) from derived_roots"), [1]);
55 assert.deepEqual(cache.column("select count(*) from derived_files"), [1]);
56
57 const qa = await download("questions.sqlite");
58 assert.ok(!qa.bytes.includes("PRIVATE"));
59 assert.deepEqual(qa.column("select text from questions"), ["q: public"]);
60 assert.deepEqual(qa.column("select name from sqlite_master where type = 'table' order by name"), [
61 "clover_migrations",
62 "questions",
63 ]);
64});
65
66test("the key gets a whole copy, and a wrong key gets nothing", async () => {
67 const qa = await download("questions.sqlite", { Authorization: "test" });
68 assert.deepEqual(qa.column("select count(*) from questions"), [3]);
69 const wrong = await app.fetch(
70 new Request("http://localhost/db/questions.sqlite", { headers: { Authorization: "?" } }),
71 );
72 assert.equal(wrong.status, 401);
73 assert.equal((await app.fetch(new Request("http://localhost/db/constructor"))).status, 404);
74});
75
76test("a copy is written again once its database changes", async () => {
77 questions.create("normal", "q: newer", new Date(Date.UTC(2030, 0)));
78 const qa = await download("questions.sqlite");
79 assert.deepEqual(qa.column("select text from questions order by qmid"), ["q: public", "q: newer"]);
80});
81
82test("a restored copy replaces every row, for connections that have the database open", async () => {
83 const db = getDatabase("restored.sqlite");
84 db.run(sql`create table questions (text text)`);
85 const response = await app.fetch(new Request("http://localhost/db/questions.sqlite"));
86 await db.restore(UNWRAP(response.body));
87 const rows = db.all<{ text: string }>(sql`select text from questions order by qmid`);
88 assert.deepEqual(rows.map((row) => row.text), ["q: public", "q: newer"]);
89});
90
91import { getDatabase } from "#sitegen/drizzle";
92import { UNWRAP } from "@clo/lib/assert";
93import { sql } from "drizzle-orm";
94import { strict as assert } from "node:assert";
95import * as crypto from "node:crypto";
96import * as fs from "node:fs";
97import * as path from "node:path";
98import { DatabaseSync } from "node:sqlite";
99import { afterAll, test, vi } from "vitest";
100import { app } from "./databases.ts";
101import "./file-viewer/models/derived.ts";
102import * as mediaFiles from "./file-viewer/models/media-files.ts";
103import * as permissions from "./file-viewer/models/permissions.ts";
104import * as questions from "./q+a/models/questions.ts";
src/databases.ts created+93
...@@ -0,0 +1,93 @@
1// Copies of the site's databases, which `node run download-db` saves into a
2// clone of this repository so that it can build the site. With the source of
3// truth's key, a copy is whole. Without it, a copy has only the tables listed
4// below, less their private rows, which is what the site shows anyone.
5export const app = new Hono();
6
7const token = process.env.CLOVER_SOT_KEY ?? null;
8
9/** per database, the tables a public copy keeps, and the statements that first delete what is private */
10const publicCopies = new Map([
11 ["cache.sqlite", {
12 tables: ["media_files", "permissions", "derived_roots", "derived_files", "derived_refs"],
13 redact: [
14 // what listings leave out: paths behind a password, and hidden or unlisted names.
15 // a directory takes its children with it, and a file its derived refs
16 `delete from media_files where exists (select 1 from permissions where lower(media_files.path) glob lower(prefix) || '*')
17 or path like '%/.%' or path like '%/\\_unlisted%' escape '\\'`,
18 "delete from permissions",
19 "delete from derived_roots where id not in (select root from derived_refs)",
20 ],
21 }],
22 ["questions.sqlite", {
23 tables: ["questions"],
24 redact: [`delete from questions where type <> ${questions.types.normal}`],
25 }],
26]);
27
28/** a connection of each database's own. copies read through it, and its `data_version` changes once another connection commits */
29const readers = new Map<string, sqlite.DatabaseSync>();
30incremental.onDispose(import.meta, () => readers.forEach((reader) => reader.close()));
31/** the `data_version` each copy was written at, by its file */
32const versions = new Map<string, number>();
33
34app.get("/db/:name", (c) => {
35 const name = c.req.param("name");
36 const publicCopy = publicCopies.get(name);
37 if (!publicCopy) return c.notFound();
38 const authorization = c.req.header("Authorization");
39 // a wrong key fails, so a deploy never builds from a public copy
40 if (authorization !== undefined && authorization !== token) {
41 return c.json({ error: "invalid authorization header" }, 401);
42 }
43 const file = getCopy(name, authorization === undefined ? publicCopy : null);
44 // a newer copy can replace the file, while this response reads the one it opened
45 const fd = fs.openSync(file, "r");
46 c.header("Content-Type", "application/vnd.sqlite3");
47 c.header("Content-Length", String(fs.fstatSync(fd).size));
48 return c.body(stream.Readable.toWeb(fs.createReadStream(file, { fd })) as ReadableStream);
49});
50
51/** a file with a copy of a database, written again once the database changes */
52function getCopy(name: string, publicCopy: { tables: string[]; redact: string[] } | null): string {
53 const { location } = getDatabase(name);
54 let reader = readers.get(location);
55 if (!reader) readers.set(location, reader = new sqlite.DatabaseSync(location, { readOnly: true }));
56 const version = Number(reader.prepare("pragma data_version;").get()?.data_version);
57 const file = `${location}.${publicCopy ? "public" : "full"}`;
58 if (versions.get(file) === version && fs.existsSync(file)) return file;
59
60 const temp = `${file}.tmp`;
61 fs.rmSync(temp, { force: true });
62 reader.prepare("vacuum into ?").run(temp);
63 if (publicCopy) {
64 using copy = new sqlite.DatabaseSync(temp);
65 copy.exec("pragma foreign_keys = on;");
66 for (const statement of publicCopy.redact) copy.exec(statement);
67 // every other table goes. a virtual table goes first, taking the tables that hold its rows
68 const objects = copy.prepare(`
69 select type, name from sqlite_master
70 where type in ('table', 'view', 'trigger') and name not like 'sqlite_%'
71 order by sql like 'create virtual%' desc
72 `).all() as Array<{ type: string; name: string }>;
73 for (const { type, name } of objects) {
74 if (type === "table" && (name === "clover_migrations" || publicCopy.tables.includes(name))) continue;
75 copy.exec(`drop ${type} if exists "${name}";`);
76 }
77 // a model creates the table again, empty, once the copy no longer says it was migrated
78 copy.exec("delete from clover_migrations where key not in (select name from sqlite_master where type = 'table');");
79 // without `secure_delete`, deleted rows linger in a file until a vacuum writes it anew
80 copy.exec("vacuum;");
81 }
82 fs.renameSync(temp, file);
83 versions.set(file, version);
84 return file;
85}
86
87import { getDatabase } from "#sitegen/drizzle";
88import * as questions from "#src/q+a/models/questions.ts";
89import * as incremental from "@clo/lib/incremental";
90import { Hono } from "hono";
91import * as fs from "node:fs";
92import * as sqlite from "node:sqlite";
93import * as stream from "node:stream";
src/file-viewer/backend.ts+1-1
...@@ -55,7 +55,7 @@ app.get("/file/*", async (c, next) => {...@@ -55,7 +55,7 @@ app.get("/file/*", async (c, next) => {
55 if (!hasCotyledonCookie) {55 if (!hasCotyledonCookie) {
56 return serveAsset(c, "/file/cotyledon-speedbump", 403);56 return serveAsset(c, "/file/cotyledon-speedbump", 403);
57 } else if (rawFilePath === "/cotyledon") {57 } else if (rawFilePath === "/cotyledon") {
58 return serveAsset(c, "/file/cotyledon-enterance", 200);58 return serveAsset(c, "/file/cotyledon-entrance", 200);
59 }59 }
60 }60 }
61 while (rawFilePath.length > 1 && rawFilePath.endsWith("/")) {61 while (rawFilePath.length > 1 && rawFilePath.endsWith("/")) {
src/file-viewer/clofi.client.ts+4-4
...@@ -553,7 +553,7 @@ async function navigate(pathname: string, pushState: boolean) {...@@ -553,7 +553,7 @@ async function navigate(pathname: string, pushState: boolean) {
553 history.pushState(null, "", `/file${filePath.length > 1 ? filePath : ""}`);553 history.pushState(null, "", `/file${filePath.length > 1 ? filePath : ""}`);
554 }554 }
555555
556 const startScrollleft = filesContainer.scrollLeft;556 const startScrollLeft = filesContainer.scrollLeft;
557557
558 if (currentSplit[0] !== filePathSplit[0]) {558 if (currentSplit[0] !== filePathSplit[0]) {
559 if (currentSplit[0] === "cotyledon") {559 if (currentSplit[0] === "cotyledon") {
...@@ -625,11 +625,11 @@ async function navigate(pathname: string, pushState: boolean) {...@@ -625,11 +625,11 @@ async function navigate(pathname: string, pushState: boolean) {
625 }625 }
626626
627 updateWidths();627 updateWidths();
628 filesContainer.scrollLeft = startScrollleft;628 filesContainer.scrollLeft = startScrollLeft;
629 requestAnimationFrame(() => {629 requestAnimationFrame(() => {
630 updateWidths();630 updateWidths();
631 filesContainer.scrollLeft = startScrollleft;631 filesContainer.scrollLeft = startScrollLeft;
632 snapToEnd(startScrollleft);632 snapToEnd(startScrollLeft);
633 });633 });
634}634}
635635
src/file-viewer/highlight.ts+2-22
...@@ -94,9 +94,9 @@ interface HighlightLinesOptions {...@@ -94,9 +94,9 @@ interface HighlightLinesOptions {
94 language: Language;94 language: Language;
95}95}
9696
97export function getStyle(scopesToCheck: string[], langugage: Language) {97export function getStyle(scopesToCheck: string[], language: Language) {
98 for (const scope of scopes) {98 for (const scope of scopes) {
99 if (scope[2] && scope[2] !== langugage) continue;99 if (scope[2] && scope[2] !== language) continue;
100 const find = scopesToCheck.find((s) => s.startsWith(scope[0]));100 const find = scopesToCheck.find((s) => s.startsWith(scope[0]));
101 if (find) {101 if (find) {
102 return scope[1];102 return scope[1];
...@@ -197,26 +197,6 @@ export async function highlightCode(code: string, language: Language) {...@@ -197,26 +197,6 @@ export async function highlightCode(code: string, language: Language) {
197 return html;197 return html;
198}198}
199199
200// export async function main() {
201// validate exts
202// for (const ext of languages) {
203// if (
204// !fs.existsSync(
205// path.join(import.meta.dirname, `highlight-grammar/${ext}.plist`),
206// )
207// ) {
208// console.error(`Missing grammar for ${ext}`);
209// }
210// // Sanity check
211// await highlightCode("wwwwwwwwwwwaaaaaaaaaaaaaaaa", ext);
212// }
213// }
214//
215// await highlightCode(
216// `return <Snow attr={'meow'}>snow!</Snow>`,
217// "tsx",
218// );
219//
220import * as fs from "#sitegen/fs";200import * as fs from "#sitegen/fs";
221import * as async from "@clo/lib/async";201import * as async from "@clo/lib/async";
222import * as string from "@clo/lib/string";202import * as string from "@clo/lib/string";
src/file-viewer/models/derived.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1// Uncompressed files are read directly from the media store root. Derivied1// Uncompressed files are read directly from the media store root. Derived
2// assets like compressed files, optimized images, and streamable video are2// assets like compressed files, optimized images, and streamable video are
3// stored in the `derived` folder. After scanning, the derived assets are3// stored in the `derived` folder. After scanning, the derived assets are
4// uploaded into the store (storage1/clofi-derived dataset on NAS). Since4// uploaded into the store (storage1/clofi-derived dataset on NAS). Since
src/file-viewer/pages/file.cotyledon-enterance.marko deleted-17
...@@ -1,17 +0,0 @@
1export const meta = { title: "file not found" };
2export { theme } from "../theme.ts";
3
4client import "../scripts/canvas_cotyledon.client.ts";
5
6<div class="files ctld ctld-et">
7 <media-panel
8 file=UNWRAP(mediaFiles.getByPath("/"))
9 isLast=false
10 activeFilename="cotyledon.txt"
11 hasCotyledonCookie=true
12 />
13 <cotyledon-main-readme/>
14</div>
15
16import * as mediaFiles from "../models/media-files.ts";
17import { UNWRAP } from "@clo/lib/assert";
src/file-viewer/pages/file.cotyledon-entrance.marko created+17
...@@ -0,0 +1,17 @@
1export const meta = { title: "file not found" };
2export { theme } from "../theme.ts";
3
4client import "../scripts/canvas_cotyledon.client.ts";
5
6<div class="files ctld ctld-et">
7 <media-panel
8 file=UNWRAP(mediaFiles.getByPath("/"))
9 isLast=false
10 activeFilename="cotyledon.txt"
11 hasCotyledonCookie=true
12 />
13 <cotyledon-main-readme/>
14</div>
15
16import * as mediaFiles from "../models/media-files.ts";
17import { UNWRAP } from "@clo/lib/assert";
src/file-viewer/sync.ts+4-23
...@@ -5,10 +5,8 @@...@@ -5,10 +5,8 @@
5// resolve through the same path — there is no polling and no TTL.5// resolve through the same path — there is no polling and no TTL.
6//6//
7// Pulls are ETag-guarded (no-change is a tiny 304; the etag is persisted so7// Pulls are ETag-guarded (no-change is a tiny 304; the etag is persisted so
8// a reboot does not re-download an unchanged database), land in a temp8// a reboot does not re-download an unchanged database), and land through
9// file, and are copied into .clover/cache.sqlite with sqlite's backup. The9// `db.restore`, so every open connection reads the new rows.
10// file stays the same, so every open connection reads the new rows, and the
11// incremental graph sees the change like any other write.
12const console = log.scoped("sync");10const console = log.scoped("sync");
1311
14const token = process.env.CLOVER_SOT_KEY ?? null;12const token = process.env.CLOVER_SOT_KEY ?? null;
...@@ -24,14 +22,13 @@ if (!enabled) {...@@ -24,14 +22,13 @@ if (!enabled) {
2422
25let inFlight: Promise<void> | null = null;23let inFlight: Promise<void> | null = null;
2624
27export function revalidate(): Promise<void> {25function revalidate(): Promise<void> {
28 return inFlight ??= revalidateInner().finally(() => {26 return inFlight ??= revalidateInner().finally(() => {
29 inFlight = null;27 inFlight = null;
30 });28 });
31}29}
3230
33async function revalidateInner(): Promise<void> {31async function revalidateInner(): Promise<void> {
34 const { location } = mediaFiles.db;
35 const res = await fetch(`${sotUrl}/db`, {32 const res = await fetch(`${sotUrl}/db`, {
36 headers: {33 headers: {
37 Authorization: token!,34 Authorization: token!,
...@@ -43,20 +40,7 @@ async function revalidateInner(): Promise<void> {...@@ -43,20 +40,7 @@ async function revalidateInner(): Promise<void> {
43 if (!res.ok || !res.body) {40 if (!res.ok || !res.body) {
44 throw new Error(`source of truth responded ${res.status} ${res.statusText}`);41 throw new Error(`source of truth responded ${res.status} ${res.statusText}`);
45 }42 }
4643 await mediaFiles.db.restore(res.body);
47 // another pull can be landing at once, from this process's last copy of
48 // this code or from another process
49 const tmp = `${location}.${crypto.randomUUID()}.tmp`;
50 try {
51 await stream.promises.pipeline(
52 stream.Readable.fromWeb(res.body as any),
53 fs.createWriteStream(tmp),
54 );
55 using snapshot = new sqlite.DatabaseSync(tmp, { readOnly: true });
56 await sqlite.backup(snapshot, location);
57 } finally {
58 await fsp.rm(tmp, { force: true });
59 }
60 saveEtag(res.headers.get("ETag"));44 saveEtag(res.headers.get("ETag"));
61 console.info(`database updated (${string.formatByteSize(Number(res.headers.get("Content-Length") ?? "0"))})`);45 console.info(`database updated (${string.formatByteSize(Number(res.headers.get("Content-Length") ?? "0"))})`);
62}46}
...@@ -160,9 +144,6 @@ if (enabled) {...@@ -160,9 +144,6 @@ if (enabled) {
160}144}
161145
162import * as fs from "node:fs";146import * as fs from "node:fs";
163import * as fsp from "node:fs/promises";
164import * as sqlite from "node:sqlite";
165import * as stream from "node:stream";
166147
167import * as mediaFiles from "#src/file-viewer/models/media-files.ts";148import * as mediaFiles from "#src/file-viewer/models/media-files.ts";
168149
src/file-viewer/tags/cotyledon-ending.marko+1-1
...@@ -3,7 +3,7 @@...@@ -3,7 +3,7 @@
3// This file contains spoilers for COTYLEDON3// This file contains spoilers for COTYLEDON
4// Consider reading through the entire archive before picking4// Consider reading through the entire archive before picking
5// apart this code, as this contains the ending sections, which5// apart this code, as this contains the ending sections, which
6// contains very percise storytelling. You've been warned...6// contains very precise storytelling. You've been warned...
7//7//
8// --> https://paperclover.net/file/cotyledon <--8// --> https://paperclover.net/file/cotyledon <--
9//9//
src/file-viewer/tags/cotyledon-speedbump.marko+1-1
...@@ -40,7 +40,7 @@...@@ -40,7 +40,7 @@
40 </p>40 </p>
41 <p>41 <p>
42 by entering our museum, you agree not to use your camera. flash off isn't42 by entering our museum, you agree not to use your camera. flash off isn't
43 enough; the bits and bytes are alergic even to a camera's sensor43 enough; the bits and bytes are allergic even to a camera's sensor
44 </p>44 </p>
45 <p style="font-size:0.9rem;">45 <p style="font-size:0.9rem;">
46 (in english: please do not store downloads after you're done viewing them)46 (in english: please do not store downloads after you're done viewing them)
src/file-viewer/tags/media-panel.marko+1-1
...@@ -13,7 +13,7 @@ client import "../clofi.client.ts";...@@ -13,7 +13,7 @@ client import "../clofi.client.ts";
13// ensure video stuff is loaded for CSRs13// ensure video stuff is loaded for CSRs
14client import "../../tags/clover-video.client.ts";14client import "../../tags/clover-video.client.ts";
1515
16static const cotyledonEndingFile = "/2024/for everone";16static const cotyledonEndingFile = "/2024/for everyone";
1717
18<const/{ file, isLast, activeFilename, hasCotyledonCookie, noWrapper }=input>18<const/{ file, isLast, activeFilename, hasCotyledonCookie, noWrapper }=input>
19<const/showActions=file.path !== cotyledonEndingFile>19<const/showActions=file.path !== cotyledonEndingFile>
src/file-viewer/views/lofideon.marko+1-1
...@@ -16,7 +16,7 @@ export const theme = {...@@ -16,7 +16,7 @@ export const theme = {
16 </p>16 </p>
17 <p>17 <p>
18 by entering our museum, you agree not to use your camera. flash off isn't18 by entering our museum, you agree not to use your camera. flash off isn't
19 enough; the bits and bytes are alergic even to a camera's sensor19 enough; the bits and bytes are allergic even to a camera's sensor
20 </p>20 </p>
21 <p>21 <p>
22 <sub>22 <sub>
src/pages/history-of-japan-reanimated.marko+1-1
...@@ -154,7 +154,7 @@ export const theme = {...@@ -154,7 +154,7 @@ export const theme = {
154154
155 <h2>what's next</h2>155 <h2>what's next</h2>
156 <p>156 <p>
157 from the success of this project, a much more ambitous project is being157 from the success of this project, a much more ambitious project is being
158 planned: the reanimation for 'history of the entire world, i guess'. i am158 planned: the reanimation for 'history of the entire world, i guess'. i am
159 joining the project as one of the co-hosts. we are currently doing initial159 joining the project as one of the co-hosts. we are currently doing initial
160 planning, all artists (music, visual, narration) interested should fill out160 planning, all artists (music, visual, narration) interested should fill out
src/pages/license.mdo+1-1
...@@ -22,7 +22,7 @@ refers to downloadable files available within the [paperclover.net/file](/file)...@@ -22,7 +22,7 @@ refers to downloadable files available within the [paperclover.net/file](/file)
2222
23permission is given to download, view, or otherwise store a copy of any media23permission is given to download, view, or otherwise store a copy of any media
24file. however, media files must not be uploaded, published, or otherwise24file. however, media files must not be uploaded, published, or otherwise
25distributed over the internet or other electronical communication devices.25distributed over the internet or other electronic communication devices.
26all uses of media files must be used for non-commercial purposes. 26all uses of media files must be used for non-commercial purposes.
2727
28paperclover.net provides media files as-is, without any warranty or liability.28paperclover.net provides media files as-is, without any warranty or liability.
src/pages/log.mdo+7-7
...@@ -37,7 +37,7 @@ tags: [react markdown]...@@ -37,7 +37,7 @@ tags: [react markdown]
37i published a new library named `@clo/react-markdown`. originally, i wanted to37i published a new library named `@clo/react-markdown`. originally, i wanted to
38have my own version of the `streamdown` package from vercel. but i didn't38have my own version of the `streamdown` package from vercel. but i didn't
39realize how much of a loser company they all are. i wasn't even trying and i39realize how much of a loser company they all are. i wasn't even trying and i
40made a library like a hundred times better and simpler than theres. because of40made a library like a hundred times better and simpler than theirs. because of
41the awesome success here, instead of calling mine "memo markdown", i just said41the awesome success here, instead of calling mine "memo markdown", i just said
42"yea, this covers every markdown use case for react" and called it React Markdown.42"yea, this covers every markdown use case for react" and called it React Markdown.
4343
...@@ -309,7 +309,7 @@ lighter alternatives:...@@ -309,7 +309,7 @@ lighter alternatives:
309309
310- `puppeteer` with `playwright`310- `puppeteer` with `playwright`
311- `codemirror` with `<textarea />`311- `codemirror` with `<textarea />`
312- panning to replace `msgpackr` with a custom format that handles the types needed312- planning to replace `msgpackr` with a custom format that handles the types needed
313313
314and the biggest of all, deprecating my HTML rendering framework, in favor of314and the biggest of all, deprecating my HTML rendering framework, in favor of
315using Marko. this is technically a complexity increase, except for the part315using Marko. this is technically a complexity increase, except for the part
...@@ -330,7 +330,7 @@ tags: [this site]...@@ -330,7 +330,7 @@ tags: [this site]
330330
331spent yesterday and today catching up with this page and preparing to get331spent yesterday and today catching up with this page and preparing to get
332writing heavy on the blog. i also fixed a bug on the video player where it332writing heavy on the blog. i also fixed a bug on the video player where it
333wouldnt work on latest chrome on non-av1 accelerated hardware.333wouldn't work on latest chrome on non-av1 accelerated hardware.
334334
335there were two bugs:335there were two bugs:
336336
...@@ -456,7 +456,7 @@ through all commits, but there are still some issues with missing TODOs. i haven...@@ -456,7 +456,7 @@ through all commits, but there are still some issues with missing TODOs. i haven
456456
457tags: [git], [home infra]457tags: [git], [home infra]
458458
459i moved forgejo off of sqlite and onto postgres. the only motiviation behind this was to easily backdate the repositories i had imported: [name paint bot] and a scripting language compiler i wrote. i was more comfortable doing this on a real database server than just editing the file.459i moved forgejo off of sqlite and onto postgres. the only motivation behind this was to easily backdate the repositories i had imported: [name paint bot] and a scripting language compiler i wrote. i was more comfortable doing this on a real database server than just editing the file.
460460
461the migration took a bit for me to figure out, but last year someone named Sven [did the same thing](https://sven-seeberg.de/wp/?p=1213), and found this `pgloader` command with the critical `data only` clause.461the migration took a bit for me to figure out, but last year someone named Sven [did the same thing](https://sven-seeberg.de/wp/?p=1213), and found this `pgloader` command with the critical `data only` clause.
462462
...@@ -485,7 +485,7 @@ the right place....@@ -485,7 +485,7 @@ the right place.
485what made this harder is i actually had two git instances; the second one is485what made this harder is i actually had two git instances; the second one is
486for a temporary infrastructure i'm running for [evil inc] until the486for a temporary infrastructure i'm running for [evil inc] until the
487organization gets dedicated hardware (more about this in a future post). so487organization gets dedicated hardware (more about this in a future post). so
488even if i routed `git` specially, it still wouldnt know which git server to go488even if i routed `git` specially, it still wouldn't know which git server to go
489to.489to.
490490
491**the solution**: write a custom "router" script that generates an491**the solution**: write a custom "router" script that generates an
...@@ -685,8 +685,8 @@ to do this, i am using https://keycloak.org, which supports openid connect...@@ -685,8 +685,8 @@ to do this, i am using https://keycloak.org, which supports openid connect
685provide forward auth proxying (how i protect services like copyparty,685provide forward auth proxying (how i protect services like copyparty,
686syncthing, pgadmin, and many more). i tried authelia beforehand, but i really686syncthing, pgadmin, and many more). i tried authelia beforehand, but i really
687do not recommend them due to how hard it is to configure, passkeys being687do not recommend them due to how hard it is to configure, passkeys being
688annoying to setup, and limited themes. i also dont recommend authentik, but i688annoying to setup, and limited themes. i also don't recommend authentik, but i
689couldnt figure out how to even start using it after i installed it.689couldn't figure out how to even start using it after i installed it.
690690
691keycloak is a bit stupid on config. as all the config lies in the postgres691keycloak is a bit stupid on config. as all the config lies in the postgres
692database, i can't use a config file to setup the primary realm. so instead, i692database, i can't use a config file to setup the primary realm. so instead, i
src/pages/resume.marko+2-2
...@@ -31,7 +31,7 @@ export const meta = { title: "clover's resume" };...@@ -31,7 +31,7 @@ export const meta = { title: "clover's resume" };
31 <header>31 <header>
32 <h2>technician</h2>32 <h2>technician</h2>
33 <em>2023; part time</em>33 <em>2023; part time</em>
34 <p>automotive maintainance company</p>34 <p>automotive maintenance company</p>
35 </header>35 </header>
36 <ul>36 <ul>
37 <li>pressed buttons on a computer</li>37 <li>pressed buttons on a computer</li>
...@@ -39,7 +39,7 @@ export const meta = { title: "clover's resume" };...@@ -39,7 +39,7 @@ export const meta = { title: "clover's resume" };
39 </article>39 </article>
4040
41 <footer>41 <footer>
42 <h2>eduation</h2>42 <h2>education</h2>
43 ${" "}<em>2004-now</em>43 ${" "}<em>2004-now</em>
44 <p>44 <p>
45 my life on earth has taught me more than i expected. i <br> continue to45 my life on earth has taught me more than i expected. i <br> continue to
src/paperclover.net.ts+2
...@@ -68,6 +68,7 @@ export interface Asset {...@@ -68,6 +68,7 @@ export interface Asset {
6868
69export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output> {69export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output> {
70 using incr = ref.start("sitegen!");70 using incr = ref.start("sitegen!");
71 await checkSpelling(incr);
7172
72 const site = await incr.step(async (b) => {73 const site = await incr.step(async (b) => {
73 const { siteSections, fonts, meta } = await b.import<typeof import("./site.ts")>("./src/site.ts");74 const { siteSections, fonts, meta } = await b.import<typeof import("./site.ts")>("./src/site.ts");
...@@ -426,3 +427,4 @@ import * as css from "../framework/css.ts";...@@ -426,3 +427,4 @@ import * as css from "../framework/css.ts";
426import * as fonts from "../framework/font.ts";427import * as fonts from "../framework/font.ts";
427import { getScriptId } from "../framework/marko.ts";428import { getScriptId } from "../framework/marko.ts";
428import SitegenWrap from "../framework/sitegen-wrap.marko";429import SitegenWrap from "../framework/sitegen-wrap.marko";
430import { checkSpelling } from "./spelling.ts";
src/q+a/artifacts.ts+1-1
...@@ -27,7 +27,7 @@ export const artifactMap: Record<string, Artifact> = {...@@ -27,7 +27,7 @@ export const artifactMap: Record<string, Artifact> = {
27 "mayday": ["mayday", "/file/2022/mayday/mayday.mp4", "music"],27 "mayday": ["mayday", "/file/2022/mayday/mayday.mp4", "music"],
28 "money-visual-cover": ["money visual cover", "/file/2021/money-visual-cover/money-visual-cover.mp4", "video"],28 "money-visual-cover": ["money visual cover", "/file/2021/money-visual-cover/money-visual-cover.mp4", "video"],
29 "mystery-of-life": ["mystery of life", "/file/2022/mystery-of-life/mystery-of-life.mp4", "music"],29 "mystery-of-life": ["mystery of life", "/file/2022/mystery-of-life/mystery-of-life.mp4", "music"],
30 "phoenix-write": ["Pheonix, WRITE!", "/file/2021/phoenix-write", "game"],30 "phoenix-write": ["Phoenix, WRITE!", "/file/2021/phoenix-write", "game"],
31 "phoenix-write-soundtrack": ["Phoenix, WRITE! soundtrack", "/file/2021/phoenix-write/OST", "music"],31 "phoenix-write-soundtrack": ["Phoenix, WRITE! soundtrack", "/file/2021/phoenix-write/OST", "music"],
32 "programming-comparison": ["thursday programming language comparison", "", "video"],32 "programming-comparison": ["thursday programming language comparison", "", "video"],
33 "test-video-1": ["test video 1", "/file/2017/test-video1.mp4", "video"],33 "test-video-1": ["test video 1", "/file/2017/test-video1.mp4", "video"],
src/q+a/backend.ts+6
...@@ -167,6 +167,11 @@ app.patch("/admin/q+a/:id", async (c, next) => {...@@ -167,6 +167,11 @@ app.patch("/admin/q+a/:id", async (c, next) => {
167 questions.updateByQmid(question.qmid, form.text, form.type);167 questions.updateByQmid(question.qmid, form.text, form.type);
168 return c.json({ success: true, message: "ok" });168 return c.json({ success: true, message: "ok" });
169});169});
170app.post("/admin/q+a/spell", async (c) => {
171 const { text } = await c.req.raw.json();
172 if (typeof text !== "string") return questionFailure(c, 400, "Bad Request");
173 return c.json({ issues: await cspell.checkText(text) });
174});
170app.get("/admin/q+a/:id", async (c, next) => {175app.get("/admin/q+a/:id", async (c, next) => {
171 const id = c.req.param("id");176 const id = c.req.param("id");
172 const timestamp = questionIdToTimestamp(id);177 const timestamp = questionIdToTimestamp(id);
...@@ -218,6 +223,7 @@ import { type Context, Hono } from "hono";...@@ -218,6 +223,7 @@ import { type Context, Hono } from "hono";
218import type { ContentfulStatusCode } from "hono/utils/http-status";223import type { ContentfulStatusCode } from "hono/utils/http-status";
219import { adjectives, animals, colors, uniqueNamesGenerator } from "unique-names-generator";224import { adjectives, animals, colors, uniqueNamesGenerator } from "unique-names-generator";
220import { hasAdminToken } from "../admin.ts";225import { hasAdminToken } from "../admin.ts";
226import * as cspell from "../cspell.ts";
221import { formatQuestionId, questionIdToTimestamp } from "./format.ts";227import { formatQuestionId, questionIdToTimestamp } from "./format.ts";
222import { serveQuestionImage } from "./image.ts";228import { serveQuestionImage } from "./image.ts";
223import * as lolZip from "./lol.zip/backend.ts";229import * as lolZip from "./lol.zip/backend.ts";
src/q+a/models/questions.ts+7-1
...@@ -15,7 +15,13 @@ const dbQuestions = sqliteTable("questions", {...@@ -15,7 +15,13 @@ const dbQuestions = sqliteTable("questions", {
15 /** for type="pending" questions, this is a JSON payload */15 /** for type="pending" questions, this is a JSON payload */
16 text: text().notNull(),16 text: text().notNull(),
17});17});
18db.migrate(dbQuestions, 1);18db.migrate(dbQuestions, 2, {
19 // a search index nothing reads, which kept the text of rejected and deleted questions
20 2: (db) => {
21 db.run(sql`drop trigger if exists questions_ai`);
22 db.run(sql`drop table if exists questions_fts`);
23 },
24});
1925
20export type Question = ReturnType<typeof toQuestion>;26export type Question = ReturnType<typeof toQuestion>;
2127
src/q+a/scripts/editor.client.ts+98-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import type { SpellRange } from "#src/cspell.ts";
1import QuestionRender from "#src/q+a/tags/question.marko";2import QuestionRender from "#src/q+a/tags/question.marko";
2import type { ScriptPayload } from "#src/q+a/views/editor.marko";3import type { ScriptPayload } from "#src/q+a/views/editor.marko";
34
...@@ -7,6 +8,8 @@ const date = new Date(payload.date);...@@ -7,6 +8,8 @@ const date = new Date(payload.date);
7const main = document.getElementById("edit-grid")! as HTMLDivElement;8const main = document.getElementById("edit-grid")! as HTMLDivElement;
8const preview = document.getElementById("preview")! as HTMLDivElement;9const preview = document.getElementById("preview")! as HTMLDivElement;
9const editor = document.getElementById("editor") as HTMLTextAreaElement;10const editor = document.getElementById("editor") as HTMLTextAreaElement;
11const overlay = document.getElementById("editor-overlay")!;
12const menu = document.getElementById("spell-menu")!;
1013
11async function updatePreview(text: string) {14async function updatePreview(text: string) {
12 preview.innerHTML = await QuestionRender.render({15 preview.innerHTML = await QuestionRender.render({
...@@ -22,9 +25,103 @@ async function updatePreview(text: string) {...@@ -22,9 +25,103 @@ async function updatePreview(text: string) {
22}25}
23updatePreview(payload.text);26updatePreview(payload.text);
2427
28const flagged = new Map<HTMLSpanElement, SpellRange>();
29
30function paintSpelling(text: string, issues: SpellRange[]) {
31 const parts: (string | Node)[] = [];
32 flagged.clear();
33 let at = 0;
34 for (const issue of issues) {
35 const { offset, length } = issue;
36 if (offset < at) continue;
37 const word = document.createElement("span");
38 word.textContent = text.slice(offset, offset + length);
39 flagged.set(word, issue);
40 parts.push(text.slice(at, offset), word);
41 at = offset + length;
42 }
43 parts.push(text.slice(at));
44 overlay.replaceChildren(...parts);
45}
46
47/** The overlay spans sit exactly where the textarea draws the same words. */
48function issueAt(x: number, y: number) {
49 for (const [word, issue] of flagged) {
50 for (const rect of word.getClientRects()) {
51 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return issue;
52 }
53 }
54 return null;
55}
56
57function closeMenu() {
58 menu.hidden = true;
59}
60
61function openMenu(issue: SpellRange, x: number, y: number) {
62 const wrap = menu.parentElement!.getBoundingClientRect();
63 menu.replaceChildren(...issue.suggestions.map((word) => {
64 const option = document.createElement("button");
65 option.textContent = word;
66 option.addEventListener("click", () => {
67 closeMenu();
68 editor.focus();
69 editor.setSelectionRange(issue.offset, issue.offset + issue.length);
70 // Going through the editing command keeps the textarea's own undo history.
71 document.execCommand("insertText", false, word);
72 });
73 return option;
74 }));
75 menu.style.left = `${x - wrap.left}px`;
76 menu.style.top = `${y - wrap.top}px`;
77 menu.hidden = false;
78}
79
80editor.addEventListener("contextmenu", (event) => {
81 const issue = issueAt(event.clientX, event.clientY);
82 if (!issue?.suggestions.length) return;
83 event.preventDefault();
84 openMenu(issue, event.clientX, event.clientY);
85});
86document.addEventListener("pointerdown", (event) => {
87 if (!menu.contains(event.target as Node)) closeMenu();
88});
89document.addEventListener("keydown", (event) => {
90 if (event.key === "Escape") closeMenu();
91});
92
93let spellTimer: ReturnType<typeof setTimeout> | undefined;
94let spellRequest = 0;
95async function checkSpelling(text: string) {
96 const request = ++spellRequest;
97 const res = await fetch("/admin/q+a/spell", {
98 method: "POST",
99 headers: {
100 Accept: "application/json",
101 "Content-Type": "application/json",
102 },
103 body: JSON.stringify({ text }),
104 });
105 if (!res.ok) return;
106 const { issues } = await res.json() as { issues: SpellRange[] };
107 if (request === spellRequest) paintSpelling(text, issues);
108}
109
25editor.value = payload.text;110editor.value = payload.text;
111paintSpelling(payload.text, []);
112checkSpelling(payload.text);
26editor.addEventListener("input", () => {113editor.addEventListener("input", () => {
27 updatePreview(editor.value);114 updatePreview(editor.value);
115 closeMenu();
116 // Offsets from a request in flight no longer line up with what is on screen.
117 spellRequest++;
118 paintSpelling(editor.value, []);
119 clearTimeout(spellTimer);
120 spellTimer = setTimeout(() => checkSpelling(editor.value), 300);
121});
122editor.addEventListener("scroll", () => {
123 overlay.scrollTop = editor.scrollTop;
124 closeMenu();
28});125});
29editor.focus();126editor.focus();
30127
...@@ -66,7 +163,7 @@ editor.focus();...@@ -66,7 +163,7 @@ editor.focus();
66});163});
67(globalThis as any).onTypeChange = () => {164(globalThis as any).onTypeChange = () => {
68 payload.type = (document.getElementById("type") as HTMLSelectElement).value as ScriptPayload["type"];165 payload.type = (document.getElementById("type") as HTMLSelectElement).value as ScriptPayload["type"];
69 updatePreview(view.state.doc.toString());166 updatePreview(editor.value);
70};167};
71168
72function wrapAction(cb: () => Promise<void>) {169function wrapAction(cb: () => Promise<void>) {
src/q+a/views/editor.css+68-8
...@@ -28,15 +28,75 @@...@@ -28,15 +28,75 @@
28 margin-top: 0;28 margin-top: 0;
29 }29 }
30}30}
31#editor {31#editor-wrap {
32 background-color: #303030;32 position: relative;
33 overflow-y: scroll;33 min-height: 0;
34 height: 100%;34 margin-top: 1rem;
35 width: calc(100% - 20px);35 width: calc(100% - 20px);
36 resize: none;36}
37/* The overlay only draws squiggles, so its text metrics must match the textarea. */
38#editor, #editor-overlay {
39 position: absolute;
40 inset: 0;
41 margin: 0 !important;
42 box-sizing: border-box;
43 border: 2px solid transparent;
44 padding: 0.5rem;
45 font-family: monospace;
46 font-size: inherit;
47 line-height: normal;
48 white-space: pre-wrap;
49 overflow-wrap: break-word;
50 overflow-y: scroll;
51 height: auto;
37 max-width: unset;52 max-width: unset;
38}53}
39.cm-scroller {54#editor {
40 overflow-y: auto;55 background-color: transparent;
41 height: 100%;56 border-color: black;
57 resize: none;
58 &:focus-visible {
59 border-color: var(--a);
60 }
61}
62#editor-overlay {
63 background-color: #303030;
64 color: transparent;
65 user-select: none;
66 pointer-events: none;
67 scrollbar-color: transparent transparent;
68
69 span {
70 text-decoration: underline wavy #f55;
71 text-decoration-skip-ink: none;
72 }
73}
74#editor-overlay::-webkit-scrollbar-thumb,
75#editor-overlay::-webkit-scrollbar-track {
76 background: transparent;
77}
78#spell-menu {
79 position: absolute;
80 z-index: 1;
81 display: flex;
82 flex-direction: column;
83 border: 2px solid var(--a);
84 background-color: #242424;
85
86 &[hidden] {
87 display: none;
88 }
89 button {
90 margin: 0;
91 border: none;
92 padding: 0.25rem 0.5rem;
93 background-color: transparent;
94 text-align: left;
95
96 &:hover, &:focus-visible {
97 background-color: var(--a);
98 color: #000;
99 outline: none;
100 }
101 }
42}102}
src/q+a/views/editor.marko+5-1
...@@ -46,7 +46,11 @@ export interface ScriptPayload {...@@ -46,7 +46,11 @@ export interface ScriptPayload {
46 <option value="reject">reject</option>46 <option value="reject">reject</option>
47 </select>47 </select>
48 </div>48 </div>
49 <textarea#editor/>49 <div#editor-wrap>
50 <div#editor-overlay aria-hidden="true"/>
51 <textarea#editor spellcheck="false"/>
52 <div#spell-menu hidden/>
53 </div>
50 <main#preview>54 <main#preview>
51 <question editor ...{ question }/>55 <question editor ...{ question }/>
52 </main>56 </main>
src/site.ts+1-1
...@@ -110,7 +110,7 @@ export const fonts: sg.Font[] = [...@@ -110,7 +110,7 @@ export const fonts: sg.Font[] = [
110 {110 {
111 asset: "/cydn_header.[ext]",111 asset: "/cydn_header.[ext]",
112 layoutFeatures,112 layoutFeatures,
113 unicodes: "cotlyedon",113 unicodes: "cotyledon",
114 },114 },
115 ],115 ],
116 },116 },
src/spelling.ts created+100
...@@ -0,0 +1,100 @@
1export async function checkSpelling(ref: incremental.Ref): Promise<number> {
2 using scope = ref.start("spelling");
3 const config = scope.step(async (b) => {
4 const settings = await b.readFile("cspell.json").json<cspell.Settings>();
5 const pending: ts.JsonValue[] = [settings];
6 for (const value of pending) {
7 if (!value || typeof value !== "object") continue;
8 if ("import" in value || "dictionaryDefinitions" in value) {
9 throw new Error(
10 "Keep spelling vocabulary in cspell.json's words or overrides; external dictionaries and imports are unsupported.",
11 );
12 }
13 pending.push(...Object.values(value));
14 }
15 const packages = await b.readFile("package-lock.json").hash();
16 cspellLib.clearCaches();
17 return { settings, packages };
18 });
19 const files = await scope.step(async (b, config) => {
20 const { settings } = await b.readStep(config);
21 const found: string[] = [];
22 const pending = ["src", "lib"];
23 for (const dir of pending) {
24 for (const name of await b.readDir(dir)) {
25 const file = `${dir}/${name}`;
26 // This boundary also applies if the configurable exclusions change.
27 if (settings.ignorePaths.some((glob) => path.matchesGlob(file, glob))) continue;
28 if (name.endsWith("/")) pending.push(file.slice(0, -1));
29 else if (settings.files.some((glob) => path.matchesGlob(file, glob))) found.push(file);
30 }
31 }
32 return found;
33 }, config).read();
34
35 await Promise.all(files.map((file) => scope.step(checkFile, file, config).read()));
36 return files.length;
37}
38
39async function checkFile(
40 b: incremental.Build,
41 file: string,
42 config: incremental.Step<{ settings: cspell.Settings; packages: string }>,
43) {
44 const { settings } = await b.readStep(config);
45 const source = b.readFile(file);
46 const text = await source.text();
47 const { issues, settingsUsed } = await cspell.spellCheck(
48 source.url,
49 text,
50 settings,
51 new URL(b.readFile("cspell.json").url),
52 );
53 if (!issues.length) return;
54
55 const document = cspellLib.createTextDocument({ uri: source.url, content: text });
56 const byWord = await cspell.suggestFixes({ issues, settingsUsed });
57 for (const issue of issues) {
58 const position = document.positionAt(issue.offset);
59 const suggestions = byWord.get(issue.text) ?? [];
60 const hint = suggestions.length
61 ? ` Did you mean ${suggestions.map((word) => JSON.stringify(word)).join(", ")}?`
62 : "";
63 b.warn(Object.assign(new Error(`Unknown word ${JSON.stringify(issue.text)}.${hint}`), {
64 name: "Spelling",
65 file,
66 line: position.line + 1,
67 col: position.character + 1,
68 }));
69 }
70}
71
72export async function main(graph: incremental.Graph) {
73 // A standalone check must not retire the site's outputs from its database.
74 const checks = new incremental.Graph({ root: graph.root, db: ".clover/spelling.db" });
75 const { checkSpelling } = await checks.import<typeof import("./spelling.ts")>("src/spelling.ts", { main: true });
76 const options = { buildAndWriteOutput: checkSpelling, progress: progress.globalRoot };
77 if (process.argv.includes("--watch")) {
78 for await (const result of checks.incrementalWatch(options)) {
79 if (!result.ok) log.error(result.error);
80 log.info(result.statusLine);
81 }
82 } else {
83 try {
84 const count = await checks.incrementalBuild(options);
85 log.info(`Spelling checked in ${count} file${count === 1 ? "" : "s"}.`);
86 } catch (error) {
87 if (!(error instanceof incremental.BuildError)) throw error;
88 log.error(error);
89 process.exitCode = 1;
90 }
91 }
92}
93
94import * as incremental from "@clo/lib/incremental";
95import * as log from "@clo/lib/log";
96import * as progress from "@clo/lib/progress";
97import type * as ts from "@clo/lib/ts";
98import * as cspellLib from "cspell-lib";
99import * as path from "node:path";
100import * as cspell from "./cspell.ts";