| author | |
| committer | |
| log | 7cd13df720d3e3b96d29bdb8f8b2507840fa3b90 |
| tree | 69f6faf2e745992d5e6f684274f3cb2a40a2631a |
| parent | 969a517e8ec98905a8cd28d8e415898c93145609 |
| signature | Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs (~clover) |
Use CSpell and `@clo/lib/incremental` to add spell checking to the
development server.
Assisted-by: gpt-6-astra
Assisted-by: gpt-6.1-sol53 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 | 13 | * if any of that fails, nothing changed, and the module does not load. |
| 14 | 14 | */ |
| 15 | 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 | } |
| 17 | 23 | |
| 18 | 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 | 34 | db = Object.assign(drizzle({ client }), { |
| 29 | 35 | location, |
| 30 | 36 | migrate: (table: SQLiteTable, version: number, steps = {}) => migrate(location, table, version, steps), |
| 37 | restore: (copy: ReadableStream<Uint8Array>) => restore(location, copy), | |
| 31 | 38 | }); |
| 32 | 39 | databases.set(file, db); |
| 33 | 40 | return db; |
| ... | ... | @@ -109,6 +116,18 @@ function migrate( |
| 109 | 116 | } |
| 110 | 117 | } |
| 111 | 118 | |
| 119 | async 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 | 131 | /** the statements that create a table as drizzle defines it, the table first and then its indexes */ |
| 113 | 132 | export function schema(table: SQLiteTable, as?: string): string[] { |
| 114 | 133 | const { name, columns, indexes, foreignKeys, primaryKeys, uniqueConstraints, checks } = getTableConfig(table); |
| ... | ... | @@ -179,4 +198,4 @@ import { |
| 179 | 198 | } from "drizzle-orm/sqlite-core"; |
| 180 | 199 | import * as fs from "node:fs"; |
| 181 | 200 | import * as path from "node:path"; |
| 182 | import { DatabaseSync } from "node:sqlite"; | |
| 201 | import { backup, DatabaseSync } from "node:sqlite"; |
lib/async.ts+2-2| ... | ... | @@ -286,8 +286,8 @@ export function deferred<T>(promise: Promise<T>): Deferred<T> { |
| 286 | 286 | async [Symbol.asyncDispose]() { |
| 287 | 287 | if (!handled) await promise; |
| 288 | 288 | }, |
| 289 | then(onFufilled, onRejected) { | |
| 290 | return promise.finally(() => (handled = true)).then(onFufilled, onRejected); | |
| 289 | then(onFulfilled, 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 | 14 | * In the above, the page render steps act individually, only rebuilding the |
| 15 | 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 | 18 | * - Changing post contents but not frontmatter does not re-run `renderHomepage` |
| 19 | 19 | * |
| 20 | 20 | * Since return values of steps are cached, [i need to continue writing this] |
| ... | ... | @@ -2466,7 +2466,7 @@ function groupErrors(entries: ErrorEntry[]): Error[] { |
| 2466 | 2466 | * within a {@linkcode Builder} function. |
| 2467 | 2467 | */ |
| 2468 | 2468 | export 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 | 2470 | url: string; |
| 2471 | 2471 | /** "file", "directory", "missing", "other", or a source's protocol. */ |
| 2472 | 2472 | kind: string; |
| ... | ... | @@ -4940,6 +4940,8 @@ const pageHeader = "clover-page"; |
| 4940 | 4940 | const libPath = "/.clover/lib/"; |
| 4941 | 4941 | const socketPath = "/.clover/socket"; |
| 4942 | 4942 | const graphPath = "/.clover/graph"; |
| 4943 | /** where a page posts a file, with its `:line:col`, to open in the editor */ | |
| 4944 | const openPath = "/.clover/open"; | |
| 4943 | 4945 | /** the visualization alone, which can be watched before anything else is served */ |
| 4944 | 4946 | const homePath = "/.clover"; |
| 4945 | 4947 | const libDir = url.fileURLToPath(new URL("./", import.meta.url)); |
| ... | ... | @@ -5047,6 +5049,15 @@ export class Debugger { |
| 5047 | 5049 | }; |
| 5048 | 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 | 5061 | const check = request.headers.has(pageHeader); |
| 5051 | 5062 | const navigated = request.headers.get("sec-fetch-dest") === "document"; |
| 5052 | 5063 | // 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 | 5391 | import * as progress from "./progress.ts"; |
| 5381 | 5392 | import * as repl from "./repl.ts"; |
| 5382 | 5393 | import * as ansi from "./string/ansi.ts"; |
| 5394 | import * as subprocess from "./subprocess.ts"; | |
| 5383 | 5395 | import * as ts from "./ts.ts"; |
| 5384 | 5396 | |
| 5385 | 5397 | import { 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 | 91 | assert.equal(await (await get("/.clover/lib/%2e%2e/run.js")).text(), "from the site"); |
| 92 | 92 | }); |
| 93 | 93 | |
| 94 | test.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 | 133 | // ## the socket |
| 95 | 134 | test("a page hears of every build, its progress, and its errors", async () => { |
| 96 | 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 | 390 | } |
| 352 | 391 | |
| 353 | 392 | import assert from "node:assert/strict"; |
| 393 | import * as fs from "node:fs"; | |
| 354 | 394 | import * as net from "node:net"; |
| 395 | import * as os from "node:os"; | |
| 355 | 396 | import * as path from "node:path"; |
| 356 | 397 | import * as consumers from "node:stream/consumers"; |
| 357 | 398 | import * 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 | 346 | }); |
| 347 | 347 | |
| 348 | 348 | test("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 | 350 | let runs = 0; |
| 351 | 351 | const graph = p.graph(); |
| 352 | 352 | const site = () => |
| ... | ... | @@ -354,16 +354,16 @@ test("warnings fail the build without stopping the step", async () => { |
| 354 | 354 | ref.step(async function spellcheck(b) { |
| 355 | 355 | runs++; |
| 356 | 356 | const text = await b.readFile("post.md").text(); |
| 357 | const col = text.indexOf("teh"); | |
| 357 | const col = text.indexOf("cat"); | |
| 358 | 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 | 361 | return text.toUpperCase(); |
| 362 | 362 | }).read()); |
| 363 | 363 | |
| 364 | 364 | for (const cached of [false, true]) { |
| 365 | 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 | 367 | const [lint] = error.errors as Array<Error & { fatal: boolean; cached: boolean; line: number }>; |
| 368 | 368 | assert.equal(lint!.message, "misspelled 'the'"); |
| 369 | 369 | assert.equal(lint!.fatal, false); |
| ... | ... | @@ -402,12 +402,12 @@ test("errors thrown from the same place are grouped, and identical ones merge", |
| 402 | 402 | }); |
| 403 | 403 | |
| 404 | 404 | test("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 | 406 | const error = await build(p.graph(), (ref) => |
| 407 | 407 | ref.step(async function spellcheck(b) { |
| 408 | 408 | const text = await b.readFile("post.md").text(); |
| 409 | for (const match of text.matchAll(/teh/g)) { | |
| 410 | b.warn(Object.assign(new Error("misspelled 'the'"), { file: "post.md", line: 1, col: match.index + 1 })); | |
| 409 | for (const match of text.matchAll(/meow/g)) { | |
| 410 | b.warn(Object.assign(new Error("flag 'meow'"), { file: "post.md", line: 1, col: match.index + 1 })); | |
| 411 | 411 | } |
| 412 | 412 | }).read()).catch((error) => error) as incremental.BuildError; |
| 413 | 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 | 117 | failed: "#ff3b3b", |
| 118 | 118 | }; |
| 119 | 119 | |
| 120 | /** a problem or note that is open, and not folding away */ | |
| 121 | const unfolded = "[open]:not(.folding)"; | |
| 122 | ||
| 123 | /** how a fold comes to rest, fast and then exponentially slower, like the graph's motion */ | |
| 124 | const ease = `linear(${ | |
| 125 | Array.from({ length: 17 }, (_, i) => ((1 - Math.exp(-6 * i / 16)) / (1 - Math.exp(-6))).toFixed(4)) | |
| 126 | })`; | |
| 127 | const easeMs = 360; | |
| 128 | ||
| 120 | 129 | const tabButtons = tabs.map((name) => |
| 121 | 130 | `<button type=button role=tab id=tab-${name} aria-controls=view-${name}>${name}</button>` |
| 122 | 131 | ).join(""); |
| ... | ... | @@ -158,7 +167,7 @@ shadow.innerHTML = `<style>${css()}</style> |
| 158 | 167 | <div class=slot></div> |
| 159 | 168 | <div class=problems></div> |
| 160 | 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>`; | |
| 162 | 171 | |
| 163 | 172 | const find = <E extends Element = HTMLElement>(selector: string) => UNWRAP(shadow.querySelector<E>(selector)); |
| 164 | 173 | const button = find<HTMLButtonElement>(".clover"); |
| ... | ... | @@ -195,7 +204,7 @@ function render() { |
| 195 | 204 | button.setAttribute("aria-expanded", String(visible)); |
| 196 | 205 | const ago = last && string.formatDurationLetters(-Math.round((Date.now() - last.at) / 1000)); |
| 197 | 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 ?? ""; | |
| 199 | 208 | |
| 200 | 209 | panel.hidden = !visible; |
| 201 | 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 | 272 | <path d="M1.5 1.5h9v9h-9zM3.5 6h5" /><path class=unfold d="M6 3.5v5" /></svg></button>`; |
| 264 | 273 | |
| 265 | 274 | function showFold() { |
| 266 | const open = shadow.querySelector("details.diagnostic[open]") !== null; | |
| 275 | const open = shadow.querySelector(`details.diagnostic${unfolded}`) !== null; | |
| 267 | 276 | for (const button of shadow.querySelectorAll<HTMLElement>(".fold")) { |
| 268 | 277 | button.classList.toggle("folded", !open); |
| 269 | 278 | button.setAttribute("aria-label", open ? "fold every problem" : "unfold every problem"); |
| ... | ... | @@ -291,10 +300,8 @@ function title(state: keyof typeof titles): string { |
| 291 | 300 | function diagnostic(of: stack.Diagnostic): string { |
| 292 | 301 | const head = (label: string, text: string) => |
| 293 | 302 | `<b>${string.escapeHtmlContent(label)}</b><span class=colon>:</span> ${string.escapeHtmlContent(text)}`; |
| 294 | const where = of.location | |
| 295 | ? `<span class=where>${string.escapeHtmlContent((of.location.file ?? unknown) + positionOf(of.location))}</span>` | |
| 296 | : ""; | |
| 297 | let html = `<summary class=head>${head(of.label, of.message)}${where}</summary>`; | |
| 303 | const where = of.location ? `<span class=where>${fileName(of.location)}</span>` : ""; | |
| 304 | let html = `<summary class=head>${chevron}${head(of.label, of.message)}${where}</summary>`; | |
| 298 | 305 | if (of.location) html += excerpt(of.location, true); |
| 299 | 306 | if (of.fields.length > 0) { |
| 300 | 307 | const fields = of.fields.map(([name, json]) => |
| ... | ... | @@ -304,7 +311,7 @@ function diagnostic(of: stack.Diagnostic): string { |
| 304 | 311 | } |
| 305 | 312 | for (const note of of.notes) { |
| 306 | 313 | 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 | 315 | + `<div class=frames>${note.frames.map((frame) => excerpt(frame, false)).join("")}</div></details>` |
| 309 | 316 | : `<div class=note><p class=head>${head("note", note.text)}</p>` |
| 310 | 317 | + `${note.location ? excerpt(note.location, false) : ""}</div>`; |
| ... | ... | @@ -318,7 +325,7 @@ function diagnostic(of: stack.Diagnostic): string { |
| 318 | 325 | const items = of.similar.map(({ message, from }) => |
| 319 | 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 | 329 | + `<ul class=similar>${items.join("")}</ul></details>`; |
| 323 | 330 | } |
| 324 | 331 | return `<details class="diagnostic ${of.severity}"${folded ? "" : " open"}>${html}</details>`; |
| ... | ... | @@ -326,6 +333,27 @@ function diagnostic(of: stack.Diagnostic): string { |
| 326 | 333 | |
| 327 | 334 | const unknown = "[unknown source]"; |
| 328 | 335 | |
| 336 | /** what folds a problem or note, so the rest of its line can be selected */ | |
| 337 | const 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 */ | |
| 341 | function 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 | 357 | /** where a place is in its file: `:line:col`, or an anchor in a url */ |
| 330 | 358 | function positionOf({ file, line, col }: stack.Excerpt): string { |
| 331 | 359 | if (!line) return ""; |
| ... | ... | @@ -335,12 +363,7 @@ function positionOf({ file, line, col }: stack.Excerpt): string { |
| 335 | 363 | /** a place and its code: the line it points at, and with `context`, the lines around it, faded */ |
| 336 | 364 | function excerpt(place: stack.Excerpt, context: boolean): string { |
| 337 | 365 | const { file, line, fn, code, first, span, internal } = place; |
| 338 | const url = file?.startsWith("https://") ? file + positionOf(place) : null; | |
| 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>` : ""); | |
| 366 | const where = fileName(place) + (fn ? ` at <span class=fn>${string.escapeHtmlContent(fn)}</span>` : ""); | |
| 344 | 367 | |
| 345 | 368 | // blank lines around the line it points at say nothing |
| 346 | 369 | const shown = code.map((text, i) => ({ text, n: first + i })) |
| ... | ... | @@ -359,6 +382,17 @@ function excerpt(place: stack.Excerpt, context: boolean): string { |
| 359 | 382 | + (lines.length > 0 ? `<pre>${lines.join("")}</pre>` : "") + "</figure>"; |
| 360 | 383 | } |
| 361 | 384 | |
| 385 | /** a place's file, which opens in the editor, or for a runtime's own code, links to its source */ | |
| 386 | function 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 | 396 | /** a longer line, like minified code, shows this much around what it points at */ |
| 363 | 397 | const widest = 160; |
| 364 | 398 | |
| ... | ... | @@ -1282,9 +1316,22 @@ dialog.addEventListener("cancel", (event) => { |
| 1282 | 1316 | dismiss(); |
| 1283 | 1317 | }); |
| 1284 | 1318 | shadow.addEventListener("click", (event) => { |
| 1285 | if (!(event.target as Element).closest(".fold")) return; | |
| 1286 | folded = shadow.querySelector("details.diagnostic[open]") !== null; | |
| 1287 | for (const problem of shadow.querySelectorAll<HTMLDetailsElement>("details.diagnostic")) problem.open = !folded; | |
| 1319 | const target = event.target as Element; | |
| 1320 | const file = target.closest<HTMLElement>("[data-open]")?.dataset.open; | |
| 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 | 1335 | showFold(); |
| 1289 | 1336 | }); |
| 1290 | 1337 | // 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 | 1384 | @keyframes blink{50%{opacity:.2}} |
| 1338 | 1385 | .live{position:fixed;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)} |
| 1339 | 1386 | |
| 1340 | .tip{display:none;top:8px;right:46px;max-width:300px;padding:5px 8px;border:1px solid #000;background:#fff; | |
| 1341 | color:#000;line-height:1.2} | |
| 1387 | .tip{display:none;top:8px;right:46px;max-width:min(300px,calc(100vw - 54px));padding:5px 8px;border:1px solid #000; | |
| 1388 | background:#fff;color:#000;line-height:1.2;overflow-wrap:anywhere} | |
| 1342 | 1389 | /* one stroke over the border's own, so its sides are as wide as the border and join it cleanly */ |
| 1343 | 1390 | .tip::before{content:"";position:absolute;top:5px;left:100%;width:8px;height:18px; |
| 1344 | 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 | 1435 | .note>summary,.note>.head{color:#bbb} |
| 1389 | 1436 | .colon{color:#999} |
| 1390 | 1437 | .where{display:none;font-size:12px;line-height:1.4;color:#999;white-space:normal} |
| 1391 | details:not([open])>summary>.where{display:block} | |
| 1438 | :is(details:not([open]),.folding)>summary>.where{display:block} | |
| 1392 | 1439 | /* the first problem leaves room for the button that folds them all */ |
| 1393 | 1440 | .problems>details:first-of-type>summary{padding-right:26px} |
| 1394 | 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 | 1448 | figcaption{padding:3px 8px 0;font-size:12px;line-height:1.4;color:#bbb;overflow-wrap:anywhere} |
| 1402 | 1449 | .pos{color:#888} |
| 1403 | 1450 | figcaption a{color:inherit;text-decoration:none} |
| 1404 | figcaption a:hover{text-decoration:underline} | |
| 1451 | figcaption a:hover,.path:hover{text-decoration:underline} | |
| 1452 | .path{all:unset;cursor:pointer} | |
| 1453 | .path:hover{background:none} | |
| 1405 | 1454 | .fn{color:#3ee6ff} |
| 1406 | 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 | 1456 | .line{display:block;padding-right:8px;white-space:pre} |
| ... | ... | @@ -1424,10 +1473,13 @@ figcaption a:hover{text-decoration:underline} |
| 1424 | 1473 | .fields dt{color:#999} |
| 1425 | 1474 | .fields dt::after{content:":"} |
| 1426 | 1475 | .fields dd{margin:0;overflow-wrap:anywhere} |
| 1427 | summary{cursor:pointer;list-style:none;line-height:1.35} | |
| 1476 | summary{list-style:none;line-height:1.35} | |
| 1428 | 1477 | summary::-webkit-details-marker{display:none} |
| 1429 | .note>summary::before{content:"\\25B8";display:inline-block;width:14px;color:#999} | |
| 1430 | .note[open]>summary::before{content:"\\25BE"} | |
| 1478 | .chevron{display:inline-block;width:14px;color:#999;cursor:pointer;user-select:none} | |
| 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 | 1483 | .frames{display:flex;flex-direction:column;gap:4px;margin-top:4px} |
| 1432 | 1484 | .nested{padding-left:10px;border-left:2px solid #333} |
| 1433 | 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 | 1497 | |
| 1446 | 1498 | #view-graph{position:relative} |
| 1447 | 1499 | canvas{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 | 1501 | .legend{position:absolute;left:0;bottom:0;display:flex;flex-wrap:wrap;gap:2px 10px;padding:4px 6px; |
| 1450 | 1502 | background:#000c;font-size:11px;color:#ccc;pointer-events:none} |
| 1451 | 1503 | .legend i{display:inline-block;box-sizing:border-box;width:9px;height:9px;margin-right:4px;vertical-align:-1px} |
| 1452 | 1504 | .point{position:absolute;width:max-content;max-width:320px;padding:4px 6px;background:#fff;color:#000;font-size:12px; |
| 1453 | 1505 | line-height:1.2;overflow-wrap:anywhere;pointer-events:none} |
| 1454 | 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 | 1508 | .panel[data-tab=graph] .slot{position:absolute;top:10px;left:10px;right:10px;z-index:1;max-height:45%; |
| 1457 | 1509 | overflow:hidden;padding:8px 10px;background:#000c;border:2px solid #999} |
| 1458 | 1510 | |
| 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 | } |
| 1462 | 1514 |
lib/log.ts+8-8| ... | ... | @@ -189,7 +189,7 @@ export function replaceGlobalWidgetHost(host: WidgetHost) { |
| 189 | 189 | global.widget = { |
| 190 | 190 | frozen: false, |
| 191 | 191 | source: stack.capture()[0]?.file |
| 192 | ?? ("untracable call to replaceGlobalWidgetHost in " + import.meta.url), | |
| 192 | ?? ("untraceable call to replaceGlobalWidgetHost in " + import.meta.url), | |
| 193 | 193 | host, |
| 194 | 194 | version: 0, |
| 195 | 195 | }; |
| ... | ... | @@ -237,7 +237,7 @@ export function startWidget<T extends WidgetOptions>( |
| 237 | 237 | } |
| 238 | 238 | |
| 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 | 241 | * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}. |
| 242 | 242 | */ |
| 243 | 243 | export function writeOutput(text: string) { |
| ... | ... | @@ -406,10 +406,10 @@ export interface TerminalWidgetHostOptions { |
| 406 | 406 | * will patch `process.std{out,err}` to ensure write calls properly get a |
| 407 | 407 | * draw lock. |
| 408 | 408 | * |
| 409 | * this lock can be cleared by deactiving all widgets, or by calling | |
| 409 | * this lock can be cleared by deactivating all widgets, or by calling | |
| 410 | 410 | * {@linkcode getDrawLock} to force `@clo/lib` to stop drawing. |
| 411 | 411 | * |
| 412 | * currently, this lock must be able to be synchronously aquired at any | |
| 412 | * currently, this lock must be able to be synchronously acquired at any | |
| 413 | 413 | * point. if you desire an async locking function, please contact me so we |
| 414 | 414 | * can design how it would work. |
| 415 | 415 | */ |
| ... | ... | @@ -446,12 +446,12 @@ export interface TerminalWidgetHostOptions { |
| 446 | 446 | */ |
| 447 | 447 | export interface TerminalLock { |
| 448 | 448 | /** |
| 449 | * recieves ANSI escape sequences for interactive data, as well as error log | |
| 449 | * receives ANSI escape sequences for interactive data, as well as error log | |
| 450 | 450 | * content from `writeError` (should flush immediately). the interactive |
| 451 | 451 | * stream and the error stream are the same: stderr. |
| 452 | 452 | */ |
| 453 | 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 | 455 | writeOutput(text: string): void; |
| 456 | 456 | /** |
| 457 | 457 | * subscribe to the terminal size. the callback must fire synchronously |
| ... | ... | @@ -492,7 +492,7 @@ export interface WidgetHost { |
| 492 | 492 | /** |
| 493 | 493 | * what does this widget host support? |
| 494 | 494 | * - color: ANSI escape sequences are displayed and not stripped |
| 495 | * - widget: `startWidget` can meaninglyful display the widget | |
| 495 | * - widget: `startWidget` can meaningfully display the widget | |
| 496 | 496 | */ |
| 497 | 497 | capabilities: ReadonlyArray<"widget" | "color">; |
| 498 | 498 | } |
| ... | ... | @@ -1860,7 +1860,7 @@ export function defaultNodeProcessWidgetHost( |
| 1860 | 1860 | |
| 1861 | 1861 | // non-node runtimes will typically implement console in a way that |
| 1862 | 1862 | // 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 | 1864 | function patchSyncMethod<T, A extends unknown[]>( |
| 1865 | 1865 | fn: (this: T, ...args: A) => void, |
| 1866 | 1866 | ) { |
lib/meta.ts+1-1| ... | ... | @@ -6,7 +6,7 @@ export interface Meta { |
| 6 | 6 | description?: string | null; |
| 7 | 7 | /** automatically added for static renders from the 'pages' folders. */ |
| 8 | 8 | canonical?: string | null; |
| 9 | /** add `<link rel="alternate" ... />`. Object keys are interpretted as | |
| 9 | /** add `<link rel="alternate" ... />`. Object keys are interpreted as | |
| 10 | 10 | * mime types if they contain a slash, otherwise seen as an alternative language. */ |
| 11 | 11 | alternates?: Alternate[] | Record<string, string> | null; |
| 12 | 12 |
lib/progress.ts+9-9| ... | ... | @@ -58,7 +58,7 @@ export function start(text: string, opts?: StartOptions): Node { |
| 58 | 58 | /** |
| 59 | 59 | * a reference point to create sub-items. this interface is satisfied by |
| 60 | 60 | * - the `progress` module itself |
| 61 | * - a node recieved by `p.start("label")` | |
| 61 | * - a node received by `p.start("label")` | |
| 62 | 62 | * - a headless progress runner from `progress.headless()` |
| 63 | 63 | * |
| 64 | 64 | * 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 | 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 | 75 | * specifically if the caller should have some control over the node name. |
| 76 | 76 | * |
| 77 | 77 | * ```ts |
| ... | ... | @@ -90,7 +90,7 @@ export interface Ref { |
| 90 | 90 | } |
| 91 | 91 | |
| 92 | 92 | /** |
| 93 | * control for a progress nooe. all fields are primed with setters to trigger | |
| 93 | * control for a progress node. all fields are primed with setters to trigger | |
| 94 | 94 | * ui dispatch automatically. to make tools that use `progress.ts` more |
| 95 | 95 | * modular to a custom progress implementation, many fields are optional. |
| 96 | 96 | */ |
| ... | ... | @@ -102,7 +102,7 @@ export interface Node extends Ref, Disposable { |
| 102 | 102 | /** |
| 103 | 103 | * number of items completed. by default this is a count of items, but the |
| 104 | 104 | * `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 | 107 | value: number; |
| 108 | 108 | /** |
| ... | ... | @@ -127,7 +127,7 @@ export interface Node extends Ref, Disposable { |
| 127 | 127 | estimatedTime?: number | null; |
| 128 | 128 | /** |
| 129 | 129 | * 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 | 132 | showTotal?: boolean; |
| 133 | 133 | /** |
| ... | ... | @@ -153,14 +153,14 @@ export interface StartOptions { |
| 153 | 153 | /** when set above `0`, it is shown with a progress bar. */ |
| 154 | 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 | 157 | * @default "count" |
| 158 | 158 | */ |
| 159 | 159 | units?: "count" | "bytes" | "percent"; |
| 160 | 160 | /** |
| 161 | 161 | * customize how an estimate is given for progress bars. to make |
| 162 | 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 | 165 | estimateCompletion?: EstimationAlgorithm | boolean | null; |
| 166 | 166 | /** |
| ... | ... | @@ -175,7 +175,7 @@ export interface StartOptions { |
| 175 | 175 | hidden?: boolean | undefined | null; |
| 176 | 176 | /** |
| 177 | 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 | 180 | showTotal?: boolean | undefined | null; |
| 181 | 181 | /** specify sorting for children */ |
| ... | ... | @@ -1192,7 +1192,7 @@ export const contentType = "application/x-clover-progress"; |
| 1192 | 1192 | /** |
| 1193 | 1193 | * opaque but JSON-compatible serialized state, either containing the entire |
| 1194 | 1194 | * 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 change | |
| 1195 | * and backwards compatible with changes to this library. if a breaking change | |
| 1196 | 1196 | * is made, the APIs in this file will be renamed. |
| 1197 | 1197 | * |
| 1198 | 1198 | * 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 | 27 | priority?: number; |
| 28 | 28 | /** If not `true`, the given AbortSignal will never abort. */ |
| 29 | 29 | cancelable?: boolean; |
| 30 | /** @default false */ | |
| 31 | causeCancelations?: boolean; | |
| 30 | /** @default true */ | |
| 31 | causeCancellations?: boolean; | |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | 34 | export class PriorityQueue { |
| ... | ... | @@ -65,7 +65,7 @@ export class PriorityQueue { |
| 65 | 65 | if (this.canRun(job)) { |
| 66 | 66 | this.startJob(job); |
| 67 | 67 | } else { |
| 68 | if (job.causeCancelations ?? true) { | |
| 68 | if (job.causeCancellations ?? true) { | |
| 69 | 69 | // Attempt to cancel other jobs to allow pushing this one up. |
| 70 | 70 | let remain = this.coresRemain; |
| 71 | 71 | for ( |
| ... | ... | @@ -112,7 +112,7 @@ export class PriorityQueue { |
| 112 | 112 | .finally(() => { |
| 113 | 113 | this.coresRemain += job.cores; |
| 114 | 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 | 116 | active.splice(index, 1); |
| 117 | 117 | if (this.coresRemain % 1 !== 0) { |
| 118 | 118 | this.coresRemain = active.reduce( |
lib/readme.changes.md+6-2| ... | ... | @@ -5,6 +5,7 @@ |
| 5 | 5 | ### breaking |
| 6 | 6 | |
| 7 | 7 | - `subprocess` is rewritten around web streams. |
| 8 | - `queue.Job`'s cancellation option is renamed to `causeCancellations`. | |
| 8 | 9 | |
| 9 | 10 | ### features |
| 10 | 11 | |
| ... | ... | @@ -18,6 +19,9 @@ |
| 18 | 19 | pages this machine asks for: each build's status, progress, and errors, and |
| 19 | 20 | the graph of its steps. |
| 20 | 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 | 25 | - `subprocess`'s `progress` option hands a `progress.Ref` to a child process, |
| 22 | 26 | which becomes the global root of `progress` there. the child's global log is |
| 23 | 27 | written by the parent as well. `progress.childEnv` and `progress.attachChild` |
| ... | ... | @@ -86,7 +90,7 @@ |
| 86 | 90 | - `meta.toTags` creates an object compatible with TanStack Router |
| 87 | 91 | - the `extra` object now takes an array of React-like elements among some |
| 88 | 92 | other supported forms. |
| 89 | - `log` and `progress` have recieved so many bug-fix changes that it is worth | |
| 93 | - `log` and `progress` have received so many bug-fix changes that it is worth | |
| 90 | 94 | ensuring your application edge cases still work as expected. |
| 91 | 95 | - `string.formatByteSize` formats differently. |
| 92 | 96 | |
| ... | ... | @@ -159,7 +163,7 @@ deprecations without removals: |
| 159 | 163 | - `progress.ts` node gains `node.log.write("word ");` to write a message without |
| 160 | 164 | a newline. |
| 161 | 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 | 167 | - `ansi` gets more cursor control constants |
| 164 | 168 | |
| 165 | 169 | ## 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 | 31 | - `queue` - priority queue with rescheduling |
| 32 | 32 | - `repl` - interactive prompt for long running programs |
| 33 | 33 | - `stream` - ReadableStream helpers |
| 34 | - `string` - trival helpers | |
| 34 | - `string` - trivial helpers | |
| 35 | 35 | - `string/ansi` - cursor and color constants |
| 36 | 36 | - `subprocess` - rebuilt api for `node:child_process` |
| 37 | 37 | - `subprocess/ffmpeg` - spawn and parse progress output |
| ... | ... | @@ -72,7 +72,7 @@ named as single words so they can be easily imported. |
| 72 | 72 | |
| 73 | 73 | ## versioning |
| 74 | 74 | |
| 75 | these libraries are published as is, **without versioning or maintainance | |
| 75 | these libraries are published as is, **without versioning or maintenance | |
| 76 | 76 | guarantees**. every version is a breaking major release. please upgrade your |
| 77 | 77 | libraries with intent, considering api changes in [readme.changes.md]. |
| 78 | 78 | individual modules may annotate their intended api and usage stability. |
lib/stream.test.ts+2-2| ... | ... | @@ -256,8 +256,8 @@ describe("lines", () => { |
| 256 | 256 | }); |
| 257 | 257 | |
| 258 | 258 | test("a character split between chunks", async () => { |
| 259 | const bytes = string.encodeUtf8("caf\u00e9\n"); | |
| 260 | assert.deepEqual(await collect([bytes.subarray(0, 4), bytes.subarray(4)]), ["caf\u00e9"]); | |
| 259 | const bytes = string.encodeUtf8("cat\u00e9\n"); | |
| 260 | assert.deepEqual(await collect([bytes.subarray(0, 4), bytes.subarray(4)]), ["cat\u00e9"]); | |
| 261 | 261 | }); |
| 262 | 262 | |
| 263 | 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 | 231 | */ |
| 232 | 232 | export function debugAnsi(text: string): string { |
| 233 | 233 | // deno-fmt-ignore |
| 234 | const verbaitim: (keyof typeof self)[] = [ | |
| 234 | const verbatim: (keyof typeof self)[] = [ | |
| 235 | 235 | "fgCyan", |
| 236 | 236 | "fgBlue", |
| 237 | 237 | "bgReset", |
| ... | ... | @@ -278,7 +278,7 @@ export function debugAnsi(text: string): string { |
| 278 | 278 | "cursorSave", |
| 279 | 279 | "cursorRestore", |
| 280 | 280 | ]; |
| 281 | for (const key of verbaitim) { | |
| 281 | for (const key of verbatim) { | |
| 282 | 282 | text = text.replaceAll( |
| 283 | 283 | String(self[key]), |
| 284 | 284 | style(key.includes("cursor") ? fgBlue : fgCyan, `\${${key}}`), |
lib/subprocess.test.ts+93-1| ... | ... | @@ -227,12 +227,104 @@ describe("progress", () => { |
| 227 | 227 | }); |
| 228 | 228 | }); |
| 229 | 229 | |
| 230 | // windows runs editors through cmd.exe, which cannot execute these stubs | |
| 231 | describe.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 | ||
| 230 | 321 | import { strict as assert } from "node:assert"; |
| 231 | 322 | import * as fs from "node:fs"; |
| 232 | 323 | import * as os from "node:os"; |
| 233 | 324 | import * as path from "node:path"; |
| 234 | 325 | import * as process from "node:process"; |
| 235 | import { describe, test } from "vitest"; | |
| 326 | import * as url from "node:url"; | |
| 327 | import { afterAll, describe, test } from "vitest"; | |
| 236 | 328 | import { UNWRAP } from "./assert.ts"; |
| 237 | 329 | import * as async from "./async.ts"; |
| 238 | 330 | import * as exception from "./exception.ts"; |
lib/subprocess.ts+257-1| ... | ... | @@ -33,7 +33,7 @@ export async function execute( |
| 33 | 33 | */ |
| 34 | 34 | export function spawn<const O extends Options>(cmd: Command, opts?: O): Subprocess<O> { |
| 35 | 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 | 37 | signal?.throwIfAborted(); |
| 38 | 38 | |
| 39 | 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 | 42 | |
| 43 | 43 | const child = child_process.spawn(cmd[0], cmd.slice(1), { |
| 44 | 44 | cwd, |
| 45 | shell, | |
| 45 | 46 | env: { ...process.env, ...env, ...handoff && progress.ipcChildEnvironment(progressFd) }, |
| 46 | 47 | stdio, |
| 47 | 48 | }); |
| ... | ... | @@ -116,6 +117,8 @@ export interface Options { |
| 116 | 117 | stdout?: Stdio; |
| 117 | 118 | /** @default "inherit" */ |
| 118 | 119 | stderr?: Stdio; |
| 120 | /** run `cmd` through the system shell, which does not escape what it is given */ | |
| 121 | shell?: boolean; | |
| 119 | 122 | /** open a channel for messages with a child that is node.js too, where it is `process` */ |
| 120 | 123 | ipc?: boolean; |
| 121 | 124 | /** for a subprocess using `@clo/lib/process`, setup progress IPC. */ |
| ... | ... | @@ -245,12 +248,265 @@ interface ProcessLookup { |
| 245 | 248 | command: string; |
| 246 | 249 | } |
| 247 | 250 | |
| 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 | */ | |
| 262 | export 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 | ||
| 307 | export 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 */ | |
| 322 | let currentEditor: { proc: Subprocess; replaced?: boolean } | null = null; | |
| 323 | ||
| 324 | /** `file.ts:12:4`, where the line and column are optional, or a `file://` url */ | |
| 325 | function 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 */ | |
| 336 | function 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 */ | |
| 342 | function 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 */ | |
| 348 | function 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 | */ | |
| 377 | function 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 | */ | |
| 392 | async 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 */ | |
| 429 | const 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 */ | |
| 446 | const 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 | */ | |
| 453 | const 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 */ | |
| 484 | const 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 */ | |
| 493 | const 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 | ||
| 248 | 502 | import * as child_process from "node:child_process"; |
| 249 | 503 | import * as fs from "node:fs/promises"; |
| 250 | 504 | import * as os from "node:os"; |
| 251 | 505 | import * as path from "node:path"; |
| 252 | 506 | import * as process from "node:process"; |
| 253 | 507 | import * as stream from "node:stream"; |
| 508 | import * as url from "node:url"; | |
| 254 | 509 | import { ASSERT } from "./assert.ts"; |
| 510 | import * as exception from "./exception.ts"; | |
| 255 | 511 | import * as progress from "./progress.ts"; |
| 256 | 512 | import * as string from "./string.ts"; |
package-lock.json+830-3| ... | ... | @@ -10,6 +10,7 @@ |
| 10 | 10 | "@marko/compiler": "5.39.62", |
| 11 | 11 | "@types/node": "24.x", |
| 12 | 12 | "@types/wawoff2": "^1.0.2", |
| 13 | "cspell-lib": "10.4.0", | |
| 13 | 14 | "dashjs": "5.0.3", |
| 14 | 15 | "dprint": "0.53.1", |
| 15 | 16 | "drizzle-orm": "1.0.0-rc.4", |
| ... | ... | @@ -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 | 595 | "node_modules/@dprint/darwin-arm64": { |
| 62 | 596 | "version": "0.53.1", |
| 63 | 597 | "resolved": "https://registry.npmjs.org/@dprint/darwin-arm64/-/darwin-arm64-0.53.1.tgz", |
| ... | ... | @@ -1135,6 +1669,12 @@ |
| 1135 | 1669 | "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", |
| 1136 | 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 | 1678 | "node_modules/assertion-error": { |
| 1139 | 1679 | "version": "1.1.0", |
| 1140 | 1680 | "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", |
| ... | ... | @@ -1230,6 +1770,19 @@ |
| 1230 | 1770 | "integrity": "sha512-eNk3TRV+xQMJ1PEj0FQGY8KD4m0GPxT487XJ+Iftm7mVa9WpPFDMWqPt+46buiP5j5Wzqe5oMIhqBcAeKfygSA==", |
| 1231 | 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 | 1786 | "node_modules/complain": { |
| 1234 | 1787 | "version": "1.6.1", |
| 1235 | 1788 | "resolved": "https://registry.npmjs.org/complain/-/complain-1.6.1.tgz", |
| ... | ... | @@ -1245,6 +1798,125 @@ |
| 1245 | 1798 | "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", |
| 1246 | 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 | 1920 | "node_modules/csstype": { |
| 1249 | 1921 | "version": "3.2.3", |
| 1250 | 1922 | "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", |
| ... | ... | @@ -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 | 2210 | "node_modules/error-stack-parser": { |
| 1524 | 2211 | "version": "2.1.4", |
| 1525 | 2212 | "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", |
| ... | ... | @@ -1575,6 +2262,19 @@ |
| 1575 | 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 | 2278 | "node_modules/estree-walker": { |
| 1579 | 2279 | "version": "3.0.3", |
| 1580 | 2280 | "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", |
| ... | ... | @@ -1608,6 +2308,15 @@ |
| 1608 | 2308 | "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", |
| 1609 | 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 | 2320 | "node_modules/fastest-levenshtein": { |
| 1612 | 2321 | "version": "1.0.16", |
| 1613 | 2322 | "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", |
| ... | ... | @@ -1648,6 +2357,30 @@ |
| 1648 | 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 | 2384 | "node_modules/harfbuzzjs": { |
| 1652 | 2385 | "version": "0.10.1", |
| 1653 | 2386 | "resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-0.10.1.tgz", |
| ... | ... | @@ -1706,6 +2439,28 @@ |
| 1706 | 2439 | "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", |
| 1707 | 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 | 2464 | "node_modules/imsc": { |
| 1710 | 2465 | "version": "1.1.5", |
| 1711 | 2466 | "resolved": "https://registry.npmjs.org/imsc/-/imsc-1.1.5.tgz", |
| ... | ... | @@ -1715,6 +2470,15 @@ |
| 1715 | 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 | 2482 | "node_modules/is-alphabetical": { |
| 1719 | 2483 | "version": "2.0.1", |
| 1720 | 2484 | "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", |
| ... | ... | @@ -1749,6 +2513,18 @@ |
| 1749 | 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 | 2528 | "node_modules/jsesc": { |
| 1753 | 2529 | "version": "3.1.0", |
| 1754 | 2530 | "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", |
| ... | ... | @@ -2161,9 +2937,9 @@ |
| 2161 | 2937 | "license": "ISC" |
| 2162 | 2938 | }, |
| 2163 | 2939 | "node_modules/picomatch": { |
| 2164 | "version": "4.0.4", | |
| 2165 | "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", | |
| 2166 | "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", | |
| 2940 | "version": "4.0.7", | |
| 2941 | "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", | |
| 2942 | "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", | |
| 2167 | 2943 | "license": "MIT", |
| 2168 | 2944 | "engines": { |
| 2169 | 2945 | "node": ">=12" |
| ... | ... | @@ -2341,6 +3117,18 @@ |
| 2341 | 3117 | "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", |
| 2342 | 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 | 3132 | "node_modules/source-map": { |
| 2345 | 3133 | "version": "0.6.1", |
| 2346 | 3134 | "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", |
| ... | ... | @@ -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 | 3492 | "node_modules/vscode-oniguruma": { |
| 2699 | 3493 | "version": "2.0.1", |
| 2700 | 3494 | "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz", |
| ... | ... | @@ -2707,6 +3501,12 @@ |
| 2707 | 3501 | "integrity": "sha512-rkvG4SraZQaPSN/5XjwKswdU0OP9MF28QjrYzUBbhb8QyG3ljB1Ky996m++jiI7KdiAP2CkBiQZd9pqEDTClqA==", |
| 2708 | 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 | 3510 | "node_modules/warp10": { |
| 2711 | 3511 | "version": "2.1.0", |
| 2712 | 3512 | "resolved": "https://registry.npmjs.org/warp10/-/warp10-2.1.0.tgz", |
| ... | ... | @@ -2741,6 +3541,33 @@ |
| 2741 | 3541 | "engines": { |
| 2742 | 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 | "@marko/compiler": "5.39.62", |
| 7 | 7 | "@types/node": "24.x", |
| 8 | 8 | "@types/wawoff2": "^1.0.2", |
| 9 | "cspell-lib": "10.4.0", | |
| 9 | 10 | "dashjs": "5.0.3", |
| 10 | 11 | "dprint": "0.53.1", |
| 11 | 12 | "drizzle-orm": "1.0.0-rc.4", |
readme.md+4| ... | ... | @@ -33,6 +33,10 @@ minimum system requirements: |
| 33 | 33 | |
| 34 | 34 | my primary development machine is a Dell Inspiron 7348 (Core i7) with Windows 7 |
| 35 | 35 | |
| 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 | 40 | # setup and run the site locally, watching for changes. |
| 37 | 41 | node run |
| 38 | 42 |
src/backend.ts+2| ... | ... | @@ -13,6 +13,7 @@ app.route("", blog.app); |
| 13 | 13 | app.route("", qa.app); |
| 14 | 14 | app.route("", fileViewer.app); |
| 15 | 15 | app.route("", friendAuth.app); |
| 16 | app.route("", databases.app); | |
| 16 | 17 | app.route("", extra.app); |
| 17 | 18 | |
| 18 | 19 | // Asset middleware has least precedence |
| ... | ... | @@ -67,6 +68,7 @@ import { trimTrailingSlash } from "hono/trailing-slash"; |
| 67 | 68 | import * as admin from "./admin.ts"; |
| 68 | 69 | import * as extra from "./backend-extra.ts"; |
| 69 | 70 | import * as blog from "./blog/backend.ts"; |
| 71 | import * as databases from "./databases.ts"; | |
| 70 | 72 | import * as fileViewer from "./file-viewer/backend.ts"; |
| 71 | 73 | import "./file-viewer/sync.ts"; |
| 72 | 74 | import * as friendAuth from "./friend-auth.ts"; |
src/bin/deploy.ts+3-25| ... | ... | @@ -16,8 +16,8 @@ export async function main(graph: incremental.Graph) { |
| 16 | 16 | `${remote} runs node.js ${stdout.trim()}, and this build's cache is for ${process.version}`, |
| 17 | 17 | ); |
| 18 | 18 | |
| 19 | await download("cache.sqlite"); | |
| 20 | await download("questions.sqlite"); | |
| 19 | // a site that is down can't hand out its databases, and a deploy may be what brings it back | |
| 20 | await downloadDb.main().catch((error) => console.warn("building with the local databases:", error)); | |
| 21 | 21 | |
| 22 | 22 | // any error stops the deploy, including the ones a build can still be served with |
| 23 | 23 | await graph.incrementalBuild({ progress, buildAndWriteOutput }); |
| ... | ... | @@ -67,33 +67,11 @@ export async function main(graph: incremental.Graph) { |
| 67 | 67 | ], { stdin: "inherit" }).wait(); |
| 68 | 68 | } |
| 69 | 69 | |
| 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 | */ | |
| 75 | async 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 | ||
| 92 | 70 | import { Path } from "#sitegen/path"; |
| 93 | 71 | import { ASSERT } from "@clo/lib/assert"; |
| 94 | 72 | import type * as incremental from "@clo/lib/incremental"; |
| 95 | 73 | import * as progress from "@clo/lib/progress"; |
| 96 | 74 | import * as subprocess from "@clo/lib/subprocess"; |
| 97 | import * as fs from "node:fs"; | |
| 98 | 75 | import * as rsync from "../file-viewer/rsync.ts"; |
| 99 | 76 | import { buildAndWriteOutput } from "../paperclover.net.ts"; |
| 77 | import * as downloadDb from "./download-db.ts"; |
src/bin/download-db.ts+23-17| ... | ... | @@ -1,21 +1,27 @@ |
| 1 | // Pull production state for local development. cache.sqlite comes from the | |
| 2 | // source of truth's /db route (no ssh needed); questions.sqlite is still | |
| 3 | // rsync'd off the web node since the source of truth does not own it yet. | |
| 1 | // Saves copies of the site's databases into `.clover`, which is all that a | |
| 2 | // clone of this repository lacks to build the site. With CLOVER_SOT_KEY, the | |
| 3 | // copies are whole; without it, they hold what the site shows anyone. | |
| 4 | const site = "https://paperclover.net"; | |
| 5 | const token = process.env.CLOVER_SOT_KEY ?? null; | |
| 6 | ||
| 4 | 7 | export async function main() { |
| 5 | await sync.revalidate(); | |
| 6 | await rsync.spawn({ | |
| 7 | cwd: Path.resolve("."), | |
| 8 | args: [ | |
| 9 | "-a", | |
| 10 | "--progress", | |
| 11 | "clo@paperclover.net:~/paperclover.net/.clover/questions.sqlite", | |
| 12 | ".clover/questions.sqlite", | |
| 13 | ], | |
| 14 | progress: progress.start("download questions state"), | |
| 15 | }); | |
| 8 | for (const name of ["cache.sqlite", "questions.sqlite"]) { | |
| 9 | using node = progress.start(`download ${name}`, { units: "bytes" }); | |
| 10 | const response = await fetch(`${site}/db/${name}`, { headers: token ? { Authorization: token } : {} }); | |
| 11 | if (!response.ok || !response.body) { | |
| 12 | throw new Error(`${site}/db/${name} responded ${response.status} ${response.statusText}`); | |
| 13 | } | |
| 14 | node.total = Number(response.headers.get("Content-Length")); | |
| 15 | await getDatabase(name).restore(response.body.pipeThrough( | |
| 16 | new TransformStream({ | |
| 17 | transform(chunk, controller) { | |
| 18 | node.value += chunk.byteLength; | |
| 19 | controller.enqueue(chunk); | |
| 20 | }, | |
| 21 | }), | |
| 22 | )); | |
| 23 | } | |
| 16 | 24 | } |
| 17 | 25 | |
| 18 | import { Path } from "#sitegen/path"; | |
| 26 | import { getDatabase } from "#sitegen/drizzle"; | |
| 19 | 27 | import * as progress from "@clo/lib/progress"; |
| 20 | import * as rsync from "../file-viewer/rsync.ts"; | |
| 21 | import * 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 | 184 | |
| 185 | 185 | All of the above is about how Marko's syntax works, and how it performs HTML |
| 186 | 186 | generation with components. Marko also allows interactive components, but an |
| 187 | explaination of that is beyond the scope of this page, mostly since I have not | |
| 187 | explanation of that is beyond the scope of this page, mostly since I have not | |
| 188 | 188 | used it. A brief example of it, modified from their documentation. |
| 189 | 189 | |
| 190 | 190 | ```marko |
src/blog/localization.ts+2| ... | ... | @@ -15,6 +15,7 @@ export const languages: LanguageMap = { |
| 15 | 15 | translatedBy: ({ author }) => `translated by ${author}`, |
| 16 | 16 | tableOfContents: "Contents", |
| 17 | 17 | }, |
| 18 | // cspell:disable | |
| 18 | 19 | ko: { |
| 19 | 20 | title: "한국어", |
| 20 | 21 | return: "홈 페이지로 돌아가기", |
| ... | ... | @@ -29,4 +30,5 @@ export const languages: LanguageMap = { |
| 29 | 30 | translatedBy: ({ author }) => `traducido por ${author}`, |
| 30 | 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 | 37 | model, where there is code on the backend to generate HTML text and frontend |
| 38 | 38 | code to manage the DOM using `window.document.*`. |
| 39 | 39 | |
| 40 | > The first disaster: naming!! React is now using the wors | |
| 40 | > The first disaster: naming!! React is now using the words | |
| 41 | 41 | > <b.server>"server"</b> and <b.client>"client"</b> to refer to |
| 42 | 42 | > a very specific things, ignoring their existing definitions. This would be |
| 43 | 43 | > 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 | 570 | all of the Next.js stubs and gained all of the beautiful [type-safety features] |
| 571 | 571 | that the TanStack Router provides. At the end, the site performed faster from |
| 572 | 572 | every angle: Development Mode, Production page load times, Soft navigations, |
| 573 | and at a lower price than our Next depoyment with Vercel. | |
| 573 | and at a lower price than our Next deployment with Vercel. | |
| 574 | 574 | |
| 575 | 575 | [type-safety features]: https://tanstack.com/router/v1/docs/framework/react/guide/type-safety |
| 576 | 576 |
src/cspell.ts created+106| ... | ... | @@ -0,0 +1,106 @@ |
| 1 | export interface SpellRange { | |
| 2 | offset: number; | |
| 3 | length: number; | |
| 4 | suggestions: string[]; | |
| 5 | } | |
| 6 | ||
| 7 | export type Settings = Record<string, ts.JsonValue> & { files: string[]; ignorePaths: string[] }; | |
| 8 | ||
| 9 | /** CSpell annotates every unknown word; `unknownWords` decides which of them count. */ | |
| 10 | export 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. */ | |
| 40 | export 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 | ||
| 81 | let loaded: Promise<{ settings: Settings; config: URL }> | undefined; | |
| 82 | ||
| 83 | /** Live editor feedback, flagging exactly what the build-time check would. */ | |
| 84 | export 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 | ||
| 102 | import type * as ts from "@clo/lib/ts"; | |
| 103 | import * as cspellLib from "cspell-lib"; | |
| 104 | import * as fs from "node:fs/promises"; | |
| 105 | import * as path from "node:path"; | |
| 106 | import * as url from "node:url"; |
src/databases.test.ts created+104| ... | ... | @@ -0,0 +1,104 @@ |
| 1 | const 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 | }); | |
| 12 | afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); | |
| 13 | ||
| 14 | for (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 | } | |
| 30 | permissions.set("/friends", 1); | |
| 31 | questions.create("normal", "q: public"); | |
| 32 | questions.create("reject", "q: PRIVATE rejected"); | |
| 33 | questions.create("pending", JSON.stringify({ prompt: "PRIVATE pending", sourceName: "fox" })); | |
| 34 | // a table no copy lists, like the search index questions once had | |
| 35 | getDatabase("questions.sqlite").run(sql`create virtual table leftover using fts5(text)`); | |
| 36 | getDatabase("questions.sqlite").run(sql`insert into leftover values ('PRIVATE rejected')`); | |
| 37 | ||
| 38 | async 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 | ||
| 49 | test("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 | ||
| 66 | test("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 | ||
| 76 | test("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 | ||
| 82 | test("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 | ||
| 91 | import { getDatabase } from "#sitegen/drizzle"; | |
| 92 | import { UNWRAP } from "@clo/lib/assert"; | |
| 93 | import { sql } from "drizzle-orm"; | |
| 94 | import { strict as assert } from "node:assert"; | |
| 95 | import * as crypto from "node:crypto"; | |
| 96 | import * as fs from "node:fs"; | |
| 97 | import * as path from "node:path"; | |
| 98 | import { DatabaseSync } from "node:sqlite"; | |
| 99 | import { afterAll, test, vi } from "vitest"; | |
| 100 | import { app } from "./databases.ts"; | |
| 101 | import "./file-viewer/models/derived.ts"; | |
| 102 | import * as mediaFiles from "./file-viewer/models/media-files.ts"; | |
| 103 | import * as permissions from "./file-viewer/models/permissions.ts"; | |
| 104 | import * 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. | |
| 5 | export const app = new Hono(); | |
| 6 | ||
| 7 | const 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 */ | |
| 10 | const 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 */ | |
| 29 | const readers = new Map<string, sqlite.DatabaseSync>(); | |
| 30 | incremental.onDispose(import.meta, () => readers.forEach((reader) => reader.close())); | |
| 31 | /** the `data_version` each copy was written at, by its file */ | |
| 32 | const versions = new Map<string, number>(); | |
| 33 | ||
| 34 | app.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 */ | |
| 52 | function 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 | ||
| 87 | import { getDatabase } from "#sitegen/drizzle"; | |
| 88 | import * as questions from "#src/q+a/models/questions.ts"; | |
| 89 | import * as incremental from "@clo/lib/incremental"; | |
| 90 | import { Hono } from "hono"; | |
| 91 | import * as fs from "node:fs"; | |
| 92 | import * as sqlite from "node:sqlite"; | |
| 93 | import * as stream from "node:stream"; |
src/file-viewer/backend.ts+1-1| ... | ... | @@ -55,7 +55,7 @@ app.get("/file/*", async (c, next) => { |
| 55 | 55 | if (!hasCotyledonCookie) { |
| 56 | 56 | return serveAsset(c, "/file/cotyledon-speedbump", 403); |
| 57 | 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 | 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 | 553 | history.pushState(null, "", `/file${filePath.length > 1 ? filePath : ""}`); |
| 554 | 554 | } |
| 555 | 555 | |
| 556 | const startScrollleft = filesContainer.scrollLeft; | |
| 556 | const startScrollLeft = filesContainer.scrollLeft; | |
| 557 | 557 | |
| 558 | 558 | if (currentSplit[0] !== filePathSplit[0]) { |
| 559 | 559 | if (currentSplit[0] === "cotyledon") { |
| ... | ... | @@ -625,11 +625,11 @@ async function navigate(pathname: string, pushState: boolean) { |
| 625 | 625 | } |
| 626 | 626 | |
| 627 | 627 | updateWidths(); |
| 628 | filesContainer.scrollLeft = startScrollleft; | |
| 628 | filesContainer.scrollLeft = startScrollLeft; | |
| 629 | 629 | requestAnimationFrame(() => { |
| 630 | 630 | updateWidths(); |
| 631 | filesContainer.scrollLeft = startScrollleft; | |
| 632 | snapToEnd(startScrollleft); | |
| 631 | filesContainer.scrollLeft = startScrollLeft; | |
| 632 | snapToEnd(startScrollLeft); | |
| 633 | 633 | }); |
| 634 | 634 | } |
| 635 | 635 |
src/file-viewer/highlight.ts+2-22| ... | ... | @@ -94,9 +94,9 @@ interface HighlightLinesOptions { |
| 94 | 94 | language: Language; |
| 95 | 95 | } |
| 96 | 96 | |
| 97 | export function getStyle(scopesToCheck: string[], langugage: Language) { | |
| 97 | export function getStyle(scopesToCheck: string[], language: Language) { | |
| 98 | 98 | for (const scope of scopes) { |
| 99 | if (scope[2] && scope[2] !== langugage) continue; | |
| 99 | if (scope[2] && scope[2] !== language) continue; | |
| 100 | 100 | const find = scopesToCheck.find((s) => s.startsWith(scope[0])); |
| 101 | 101 | if (find) { |
| 102 | 102 | return scope[1]; |
| ... | ... | @@ -197,26 +197,6 @@ export async function highlightCode(code: string, language: Language) { |
| 197 | 197 | return html; |
| 198 | 198 | } |
| 199 | 199 | |
| 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 | // | |
| 220 | 200 | import * as fs from "#sitegen/fs"; |
| 221 | 201 | import * as async from "@clo/lib/async"; |
| 222 | 202 | import * as string from "@clo/lib/string"; |
src/file-viewer/models/derived.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | // Uncompressed files are read directly from the media store root. Derivied | |
| 1 | // Uncompressed files are read directly from the media store root. Derived | |
| 2 | 2 | // assets like compressed files, optimized images, and streamable video are |
| 3 | 3 | // stored in the `derived` folder. After scanning, the derived assets are |
| 4 | 4 | // 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 @@ |
| 1 | export const meta = { title: "file not found" }; | |
| 2 | export { theme } from "../theme.ts"; | |
| 3 | ||
| 4 | client 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 | ||
| 16 | import * as mediaFiles from "../models/media-files.ts"; | |
| 17 | import { UNWRAP } from "@clo/lib/assert"; |
src/file-viewer/pages/file.cotyledon-entrance.marko created+17| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | export const meta = { title: "file not found" }; | |
| 2 | export { theme } from "../theme.ts"; | |
| 3 | ||
| 4 | client 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 | ||
| 16 | import * as mediaFiles from "../models/media-files.ts"; | |
| 17 | import { UNWRAP } from "@clo/lib/assert"; |
src/file-viewer/sync.ts+4-23| ... | ... | @@ -5,10 +5,8 @@ |
| 5 | 5 | // resolve through the same path — there is no polling and no TTL. |
| 6 | 6 | // |
| 7 | 7 | // 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 temp | |
| 9 | // file, and are copied into .clover/cache.sqlite with sqlite's backup. The | |
| 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. | |
| 8 | // a reboot does not re-download an unchanged database), and land through | |
| 9 | // `db.restore`, so every open connection reads the new rows. | |
| 12 | 10 | const console = log.scoped("sync"); |
| 13 | 11 | |
| 14 | 12 | const token = process.env.CLOVER_SOT_KEY ?? null; |
| ... | ... | @@ -24,14 +22,13 @@ if (!enabled) { |
| 24 | 22 | |
| 25 | 23 | let inFlight: Promise<void> | null = null; |
| 26 | 24 | |
| 27 | export function revalidate(): Promise<void> { | |
| 25 | function revalidate(): Promise<void> { | |
| 28 | 26 | return inFlight ??= revalidateInner().finally(() => { |
| 29 | 27 | inFlight = null; |
| 30 | 28 | }); |
| 31 | 29 | } |
| 32 | 30 | |
| 33 | 31 | async function revalidateInner(): Promise<void> { |
| 34 | const { location } = mediaFiles.db; | |
| 35 | 32 | const res = await fetch(`${sotUrl}/db`, { |
| 36 | 33 | headers: { |
| 37 | 34 | Authorization: token!, |
| ... | ... | @@ -43,20 +40,7 @@ async function revalidateInner(): Promise<void> { |
| 43 | 40 | if (!res.ok || !res.body) { |
| 44 | 41 | throw new Error(`source of truth responded ${res.status} ${res.statusText}`); |
| 45 | 42 | } |
| 46 | ||
| 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 | } | |
| 43 | await mediaFiles.db.restore(res.body); | |
| 60 | 44 | saveEtag(res.headers.get("ETag")); |
| 61 | 45 | console.info(`database updated (${string.formatByteSize(Number(res.headers.get("Content-Length") ?? "0"))})`); |
| 62 | 46 | } |
| ... | ... | @@ -160,9 +144,6 @@ if (enabled) { |
| 160 | 144 | } |
| 161 | 145 | |
| 162 | 146 | import * as fs from "node:fs"; |
| 163 | import * as fsp from "node:fs/promises"; | |
| 164 | import * as sqlite from "node:sqlite"; | |
| 165 | import * as stream from "node:stream"; | |
| 166 | 147 | |
| 167 | 148 | import * as mediaFiles from "#src/file-viewer/models/media-files.ts"; |
| 168 | 149 |
src/file-viewer/tags/cotyledon-ending.marko+1-1| ... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 | // This file contains spoilers for COTYLEDON |
| 4 | 4 | // Consider reading through the entire archive before picking |
| 5 | 5 | // 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 | 8 | // --> https://paperclover.net/file/cotyledon <-- |
| 9 | 9 | // |
src/file-viewer/tags/cotyledon-speedbump.marko+1-1| ... | ... | @@ -40,7 +40,7 @@ |
| 40 | 40 | </p> |
| 41 | 41 | <p> |
| 42 | 42 | 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 sensor | |
| 43 | enough; the bits and bytes are allergic even to a camera's sensor | |
| 44 | 44 | </p> |
| 45 | 45 | <p style="font-size:0.9rem;"> |
| 46 | 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 | 13 | // ensure video stuff is loaded for CSRs |
| 14 | 14 | client import "../../tags/clover-video.client.ts"; |
| 15 | 15 | |
| 16 | static const cotyledonEndingFile = "/2024/for everone"; | |
| 16 | static const cotyledonEndingFile = "/2024/for everyone"; | |
| 17 | 17 | |
| 18 | 18 | <const/{ file, isLast, activeFilename, hasCotyledonCookie, noWrapper }=input> |
| 19 | 19 | <const/showActions=file.path !== cotyledonEndingFile> |
src/file-viewer/views/lofideon.marko+1-1| ... | ... | @@ -16,7 +16,7 @@ export const theme = { |
| 16 | 16 | </p> |
| 17 | 17 | <p> |
| 18 | 18 | 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 sensor | |
| 19 | enough; the bits and bytes are allergic even to a camera's sensor | |
| 20 | 20 | </p> |
| 21 | 21 | <p> |
| 22 | 22 | <sub> |
src/pages/history-of-japan-reanimated.marko+1-1| ... | ... | @@ -154,7 +154,7 @@ export const theme = { |
| 154 | 154 | |
| 155 | 155 | <h2>what's next</h2> |
| 156 | 156 | <p> |
| 157 | from the success of this project, a much more ambitous project is being | |
| 157 | from the success of this project, a much more ambitious project is being | |
| 158 | 158 | planned: the reanimation for 'history of the entire world, i guess'. i am |
| 159 | 159 | joining the project as one of the co-hosts. we are currently doing initial |
| 160 | 160 | 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 | 22 | |
| 23 | 23 | permission is given to download, view, or otherwise store a copy of any media |
| 24 | 24 | file. however, media files must not be uploaded, published, or otherwise |
| 25 | distributed over the internet or other electronical communication devices. | |
| 25 | distributed over the internet or other electronic communication devices. | |
| 26 | 26 | all uses of media files must be used for non-commercial purposes. |
| 27 | 27 | |
| 28 | 28 | paperclover.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 | 37 | i published a new library named `@clo/react-markdown`. originally, i wanted to |
| 38 | 38 | have my own version of the `streamdown` package from vercel. but i didn't |
| 39 | 39 | realize how much of a loser company they all are. i wasn't even trying and i |
| 40 | made a library like a hundred times better and simpler than theres. because of | |
| 40 | made a library like a hundred times better and simpler than theirs. because of | |
| 41 | 41 | the awesome success here, instead of calling mine "memo markdown", i just said |
| 42 | 42 | "yea, this covers every markdown use case for react" and called it React Markdown. |
| 43 | 43 | |
| ... | ... | @@ -309,7 +309,7 @@ lighter alternatives: |
| 309 | 309 | |
| 310 | 310 | - `puppeteer` with `playwright` |
| 311 | 311 | - `codemirror` with `<textarea />` |
| 312 | - panning to replace `msgpackr` with a custom format that handles the types needed | |
| 312 | - planning to replace `msgpackr` with a custom format that handles the types needed | |
| 313 | 313 | |
| 314 | 314 | and the biggest of all, deprecating my HTML rendering framework, in favor of |
| 315 | 315 | using Marko. this is technically a complexity increase, except for the part |
| ... | ... | @@ -330,7 +330,7 @@ tags: [this site] |
| 330 | 330 | |
| 331 | 331 | spent yesterday and today catching up with this page and preparing to get |
| 332 | 332 | writing heavy on the blog. i also fixed a bug on the video player where it |
| 333 | wouldnt work on latest chrome on non-av1 accelerated hardware. | |
| 333 | wouldn't work on latest chrome on non-av1 accelerated hardware. | |
| 334 | 334 | |
| 335 | 335 | there were two bugs: |
| 336 | 336 | |
| ... | ... | @@ -456,7 +456,7 @@ through all commits, but there are still some issues with missing TODOs. i haven |
| 456 | 456 | |
| 457 | 457 | tags: [git], [home infra] |
| 458 | 458 | |
| 459 | i 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. | |
| 459 | i 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. | |
| 460 | 460 | |
| 461 | 461 | the 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. |
| 462 | 462 | |
| ... | ... | @@ -485,7 +485,7 @@ the right place. |
| 485 | 485 | what made this harder is i actually had two git instances; the second one is |
| 486 | 486 | for a temporary infrastructure i'm running for [evil inc] until the |
| 487 | 487 | organization gets dedicated hardware (more about this in a future post). so |
| 488 | even if i routed `git` specially, it still wouldnt know which git server to go | |
| 488 | even if i routed `git` specially, it still wouldn't know which git server to go | |
| 489 | 489 | to. |
| 490 | 490 | |
| 491 | 491 | **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 | 685 | provide forward auth proxying (how i protect services like copyparty, |
| 686 | 686 | syncthing, pgadmin, and many more). i tried authelia beforehand, but i really |
| 687 | 687 | do not recommend them due to how hard it is to configure, passkeys being |
| 688 | annoying to setup, and limited themes. i also dont recommend authentik, but i | |
| 689 | couldnt figure out how to even start using it after i installed it. | |
| 688 | annoying to setup, and limited themes. i also don't recommend authentik, but i | |
| 689 | couldn't figure out how to even start using it after i installed it. | |
| 690 | 690 | |
| 691 | 691 | keycloak is a bit stupid on config. as all the config lies in the postgres |
| 692 | 692 | database, 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 | 31 | <header> |
| 32 | 32 | <h2>technician</h2> |
| 33 | 33 | <em>2023; part time</em> |
| 34 | <p>automotive maintainance company</p> | |
| 34 | <p>automotive maintenance company</p> | |
| 35 | 35 | </header> |
| 36 | 36 | <ul> |
| 37 | 37 | <li>pressed buttons on a computer</li> |
| ... | ... | @@ -39,7 +39,7 @@ export const meta = { title: "clover's resume" }; |
| 39 | 39 | </article> |
| 40 | 40 | |
| 41 | 41 | <footer> |
| 42 | <h2>eduation</h2> | |
| 42 | <h2>education</h2> | |
| 43 | 43 | ${" "}<em>2004-now</em> |
| 44 | 44 | <p> |
| 45 | 45 | 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 | 68 | |
| 69 | 69 | export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output> { |
| 70 | 70 | using incr = ref.start("sitegen!"); |
| 71 | await checkSpelling(incr); | |
| 71 | 72 | |
| 72 | 73 | const site = await incr.step(async (b) => { |
| 73 | 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 | 427 | import * as fonts from "../framework/font.ts"; |
| 427 | 428 | import { getScriptId } from "../framework/marko.ts"; |
| 428 | 429 | import SitegenWrap from "../framework/sitegen-wrap.marko"; |
| 430 | import { checkSpelling } from "./spelling.ts"; |
src/q+a/artifacts.ts+1-1| ... | ... | @@ -27,7 +27,7 @@ export const artifactMap: Record<string, Artifact> = { |
| 27 | 27 | "mayday": ["mayday", "/file/2022/mayday/mayday.mp4", "music"], |
| 28 | 28 | "money-visual-cover": ["money visual cover", "/file/2021/money-visual-cover/money-visual-cover.mp4", "video"], |
| 29 | 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 | 31 | "phoenix-write-soundtrack": ["Phoenix, WRITE! soundtrack", "/file/2021/phoenix-write/OST", "music"], |
| 32 | 32 | "programming-comparison": ["thursday programming language comparison", "", "video"], |
| 33 | 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 | 167 | questions.updateByQmid(question.qmid, form.text, form.type); |
| 168 | 168 | return c.json({ success: true, message: "ok" }); |
| 169 | 169 | }); |
| 170 | app.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 | }); | |
| 170 | 175 | app.get("/admin/q+a/:id", async (c, next) => { |
| 171 | 176 | const id = c.req.param("id"); |
| 172 | 177 | const timestamp = questionIdToTimestamp(id); |
| ... | ... | @@ -218,6 +223,7 @@ import { type Context, Hono } from "hono"; |
| 218 | 223 | import type { ContentfulStatusCode } from "hono/utils/http-status"; |
| 219 | 224 | import { adjectives, animals, colors, uniqueNamesGenerator } from "unique-names-generator"; |
| 220 | 225 | import { hasAdminToken } from "../admin.ts"; |
| 226 | import * as cspell from "../cspell.ts"; | |
| 221 | 227 | import { formatQuestionId, questionIdToTimestamp } from "./format.ts"; |
| 222 | 228 | import { serveQuestionImage } from "./image.ts"; |
| 223 | 229 | import * as lolZip from "./lol.zip/backend.ts"; |
src/q+a/models/questions.ts+7-1| ... | ... | @@ -15,7 +15,13 @@ const dbQuestions = sqliteTable("questions", { |
| 15 | 15 | /** for type="pending" questions, this is a JSON payload */ |
| 16 | 16 | text: text().notNull(), |
| 17 | 17 | }); |
| 18 | db.migrate(dbQuestions, 1); | |
| 18 | db.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 | }); | |
| 19 | 25 | |
| 20 | 26 | export type Question = ReturnType<typeof toQuestion>; |
| 21 | 27 |
src/q+a/scripts/editor.client.ts+98-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { SpellRange } from "#src/cspell.ts"; | |
| 1 | 2 | import QuestionRender from "#src/q+a/tags/question.marko"; |
| 2 | 3 | import type { ScriptPayload } from "#src/q+a/views/editor.marko"; |
| 3 | 4 | |
| ... | ... | @@ -7,6 +8,8 @@ const date = new Date(payload.date); |
| 7 | 8 | const main = document.getElementById("edit-grid")! as HTMLDivElement; |
| 8 | 9 | const preview = document.getElementById("preview")! as HTMLDivElement; |
| 9 | 10 | const editor = document.getElementById("editor") as HTMLTextAreaElement; |
| 11 | const overlay = document.getElementById("editor-overlay")!; | |
| 12 | const menu = document.getElementById("spell-menu")!; | |
| 10 | 13 | |
| 11 | 14 | async function updatePreview(text: string) { |
| 12 | 15 | preview.innerHTML = await QuestionRender.render({ |
| ... | ... | @@ -22,9 +25,103 @@ async function updatePreview(text: string) { |
| 22 | 25 | } |
| 23 | 26 | updatePreview(payload.text); |
| 24 | 27 | |
| 28 | const flagged = new Map<HTMLSpanElement, SpellRange>(); | |
| 29 | ||
| 30 | function 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. */ | |
| 48 | function 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 | ||
| 57 | function closeMenu() { | |
| 58 | menu.hidden = true; | |
| 59 | } | |
| 60 | ||
| 61 | function 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 | ||
| 80 | editor.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 | }); | |
| 86 | document.addEventListener("pointerdown", (event) => { | |
| 87 | if (!menu.contains(event.target as Node)) closeMenu(); | |
| 88 | }); | |
| 89 | document.addEventListener("keydown", (event) => { | |
| 90 | if (event.key === "Escape") closeMenu(); | |
| 91 | }); | |
| 92 | ||
| 93 | let spellTimer: ReturnType<typeof setTimeout> | undefined; | |
| 94 | let spellRequest = 0; | |
| 95 | async 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 | ||
| 25 | 110 | editor.value = payload.text; |
| 111 | paintSpelling(payload.text, []); | |
| 112 | checkSpelling(payload.text); | |
| 26 | 113 | editor.addEventListener("input", () => { |
| 27 | 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 | }); | |
| 122 | editor.addEventListener("scroll", () => { | |
| 123 | overlay.scrollTop = editor.scrollTop; | |
| 124 | closeMenu(); | |
| 28 | 125 | }); |
| 29 | 126 | editor.focus(); |
| 30 | 127 | |
| ... | ... | @@ -66,7 +163,7 @@ editor.focus(); |
| 66 | 163 | }); |
| 67 | 164 | (globalThis as any).onTypeChange = () => { |
| 68 | 165 | payload.type = (document.getElementById("type") as HTMLSelectElement).value as ScriptPayload["type"]; |
| 69 | updatePreview(view.state.doc.toString()); | |
| 166 | updatePreview(editor.value); | |
| 70 | 167 | }; |
| 71 | 168 | |
| 72 | 169 | function wrapAction(cb: () => Promise<void>) { |
src/q+a/views/editor.css+68-8| ... | ... | @@ -28,15 +28,75 @@ |
| 28 | 28 | margin-top: 0; |
| 29 | 29 | } |
| 30 | 30 | } |
| 31 | #editor { | |
| 32 | background-color: #303030; | |
| 33 | overflow-y: scroll; | |
| 34 | height: 100%; | |
| 31 | #editor-wrap { | |
| 32 | position: relative; | |
| 33 | min-height: 0; | |
| 34 | margin-top: 1rem; | |
| 35 | 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 | 52 | max-width: unset; |
| 38 | 53 | } |
| 39 | .cm-scroller { | |
| 40 | overflow-y: auto; | |
| 41 | height: 100%; | |
| 54 | #editor { | |
| 55 | background-color: transparent; | |
| 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 | 46 | <option value="reject">reject</option> |
| 47 | 47 | </select> |
| 48 | 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 | 54 | <main#preview> |
| 51 | 55 | <question editor ...{ question }/> |
| 52 | 56 | </main> |
src/site.ts+1-1| ... | ... | @@ -110,7 +110,7 @@ export const fonts: sg.Font[] = [ |
| 110 | 110 | { |
| 111 | 111 | asset: "/cydn_header.[ext]", |
| 112 | 112 | layoutFeatures, |
| 113 | unicodes: "cotlyedon", | |
| 113 | unicodes: "cotyledon", | |
| 114 | 114 | }, |
| 115 | 115 | ], |
| 116 | 116 | }, |
src/spelling.ts created+100| ... | ... | @@ -0,0 +1,100 @@ |
| 1 | export 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 | ||
| 39 | async 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 | ||
| 72 | export 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 | ||
| 94 | import * as incremental from "@clo/lib/incremental"; | |
| 95 | import * as log from "@clo/lib/log"; | |
| 96 | import * as progress from "@clo/lib/progress"; | |
| 97 | import type * as ts from "@clo/lib/ts"; | |
| 98 | import * as cspellLib from "cspell-lib"; | |
| 99 | import * as path from "node:path"; | |
| 100 | import * as cspell from "./cspell.ts"; |