From 7cd13df720d3e3b96d29bdb8f8b2507840fa3b90 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 15 Oct 2026 03:03:37 -0700 Subject: [PATCH] feat: spell checker Use CSpell and `@clo/lib/incremental` to add spell checking to the development server. Assisted-by: gpt-6-astra Assisted-by: gpt-6.1-sol --- cspell.json | 51 ++ framework/lib/drizzle.ts | 21 +- lib/async.ts | 4 +- lib/incremental.ts | 16 +- lib/incremental/debugger.test.ts | 41 + lib/incremental/steps.test.ts | 14 +- lib/incremental/visualization.ts | 108 ++- lib/log.ts | 16 +- lib/meta.ts | 2 +- lib/progress.ts | 18 +- lib/queue.ts | 8 +- lib/readme.changes.md | 8 +- lib/readme.md | 4 +- lib/stream.test.ts | 4 +- lib/string/ansi.ts | 4 +- lib/subprocess.test.ts | 94 +- lib/subprocess.ts | 258 +++++- package-lock.json | 833 +++++++++++++++++- package.json | 1 + readme.md | 4 + src/backend.ts | 2 + src/bin/deploy.ts | 28 +- src/bin/download-db.ts | 40 +- src/blog/drafts/webdev/marko-intro.markodown | 2 +- src/blog/localization.ts | 2 + .../webdev/one-year-next-app-router/en.mdo | 4 +- src/cspell.ts | 106 +++ src/databases.test.ts | 104 +++ src/databases.ts | 93 ++ src/file-viewer/backend.ts | 2 +- src/file-viewer/clofi.client.ts | 8 +- src/file-viewer/highlight.ts | 24 +- src/file-viewer/models/derived.ts | 2 +- ...ce.marko => file.cotyledon-entrance.marko} | 0 src/file-viewer/sync.ts | 27 +- src/file-viewer/tags/cotyledon-ending.marko | 2 +- .../tags/cotyledon-speedbump.marko | 2 +- src/file-viewer/tags/media-panel.marko | 2 +- src/file-viewer/views/lofideon.marko | 2 +- src/pages/history-of-japan-reanimated.marko | 2 +- src/pages/license.mdo | 2 +- src/pages/log.mdo | 14 +- src/pages/resume.marko | 4 +- src/paperclover.net.ts | 2 + src/q+a/artifacts.ts | 2 +- src/q+a/backend.ts | 6 + src/q+a/models/questions.ts | 8 +- src/q+a/scripts/editor.client.ts | 99 ++- src/q+a/views/editor.css | 76 +- src/q+a/views/editor.marko | 6 +- src/site.ts | 2 +- src/spelling.ts | 100 +++ 52 files changed, 2085 insertions(+), 199 deletions(-) create mode 100644 cspell.json create mode 100644 src/cspell.ts create mode 100644 src/databases.test.ts create mode 100644 src/databases.ts rename src/file-viewer/pages/{file.cotyledon-enterance.marko => file.cotyledon-entrance.marko} (100%) create mode 100644 src/spelling.ts diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000000000000000000000000000000000000..66bee56fa51b6581d2f57d34eddd138208119ade --- /dev/null +++ b/cspell.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "version": "0.2", + "language": "en,en-GB", + "unknownWords": "report-common-typos", + "minWordLength": 3, + "maxNumberOfProblems": 100000, + "maxDuplicateProblems": 100000, + "files": [ + "{src,lib}/**/*.{ts,tsx,js,jsx,mjs,cjs,json,css,html,md,mdo,markodown,marko,txt,xml,svg,yml,yaml,sh,sql,dockerfile}" + ], + "ignorePaths": [ + "src/blog/pages/**/{es,ko}.mdo", + "src/file-viewer/highlight-grammar/**", + "lib/incremental/example/out/**", + "lib/dist-npm/**", + "**/node_modules/**", + "**/.clover/**" + ], + "overrides": [ + { + "filename": "**/*.marko", + "languageId": "html" + }, + { + "filename": [ + "**/*.mdo", + "**/*.markodown" + ], + "languageId": "markdown" + } + ], + "words": [ + "fallthru", + "thru", + "thruout", + "welp" + ], + "dictionaries": [ + "typescript", + "node", + "npm", + "html", + "css", + "fonts", + "filetypes", + "bash", + "lorem-ipsum" + ], + "numSuggestions": 3 +} diff --git a/framework/lib/drizzle.ts b/framework/lib/drizzle.ts index ff674751d16b59edc7bf181335a269f95d4504e4..a2f16c978ac65eef20b9a0bdeba020f470e81d2d 100644 --- a/framework/lib/drizzle.ts +++ b/framework/lib/drizzle.ts @@ -13,6 +13,12 @@ export interface Database extends NodeSQLiteDatabase { * if any of that fails, nothing changed, and the module does not load. */ migrate(table: SQLiteTable, version: number, steps?: Record void>): void; + /** + * replace every row with those of a downloaded copy. the file stays the same, + * so connections that have it open read the new rows, and steps that read + * the old ones run again. + */ + restore(copy: ReadableStream): Promise; } /** 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 { db = Object.assign(drizzle({ client }), { location, migrate: (table: SQLiteTable, version: number, steps = {}) => migrate(location, table, version, steps), + restore: (copy: ReadableStream) => restore(location, copy), }); databases.set(file, db); return db; @@ -109,6 +116,18 @@ function migrate( } } +async function restore(location: string, copy: ReadableStream) { + // another restore can be landing at once, from this process or another + const temp = `${location}.${crypto.randomUUID()}.tmp`; + try { + await fs.promises.writeFile(temp, copy); + using snapshot = new DatabaseSync(temp, { readOnly: true }); + await backup(snapshot, location); + } finally { + await fs.promises.rm(temp, { force: true }); + } +} + /** the statements that create a table as drizzle defines it, the table first and then its indexes */ export function schema(table: SQLiteTable, as?: string): string[] { const { name, columns, indexes, foreignKeys, primaryKeys, uniqueConstraints, checks } = getTableConfig(table); @@ -179,4 +198,4 @@ import { } from "drizzle-orm/sqlite-core"; import * as fs from "node:fs"; import * as path from "node:path"; -import { DatabaseSync } from "node:sqlite"; +import { backup, DatabaseSync } from "node:sqlite"; diff --git a/lib/async.ts b/lib/async.ts index 5e2db72f289589763c025c50b773c2565e95750c..8acc70f75bbc1aa3442c8ee4b9f750bb99a91d62 100644 --- a/lib/async.ts +++ b/lib/async.ts @@ -286,8 +286,8 @@ export function deferred(promise: Promise): Deferred { async [Symbol.asyncDispose]() { if (!handled) await promise; }, - then(onFufilled, onRejected) { - return promise.finally(() => (handled = true)).then(onFufilled, onRejected); + then(onFulfilled, onRejected) { + return promise.finally(() => (handled = true)).then(onFulfilled, onRejected); }, }; } diff --git a/lib/incremental.ts b/lib/incremental.ts index 185997cc84622a1c602d55bc874ad13a21e96dcf..d8d3adb7e8582960463d21b94b35dd427c7e3650 100644 --- a/lib/incremental.ts +++ b/lib/incremental.ts @@ -14,7 +14,7 @@ * In the above, the page render steps act individually, only rebuilding the * post changed and the index. Notably, this will already handle a few fun cases: * - * - Adding a new file in `posts/` does not rebuild any pages excep the new one. + * - Adding a new file in `posts/` does not rebuild any pages except the new one. * - Changing post contents but not frontmatter does not re-run `renderHomepage` * * Since return values of steps are cached, [i need to continue writing this] @@ -2466,7 +2466,7 @@ function groupErrors(entries: ErrorEntry[]): Error[] { * within a {@linkcode Builder} function. */ export interface Input { - /** global key for the changable object, with enough information to re-query. */ + /** global key for the changeable object, with enough information to re-query. */ url: string; /** "file", "directory", "missing", "other", or a source's protocol. */ kind: string; @@ -4940,6 +4940,8 @@ const pageHeader = "clover-page"; const libPath = "/.clover/lib/"; const socketPath = "/.clover/socket"; const graphPath = "/.clover/graph"; +/** where a page posts a file, with its `:line:col`, to open in the editor */ +const openPath = "/.clover/open"; /** the visualization alone, which can be watched before anything else is served */ const homePath = "/.clover"; const libDir = url.fileURLToPath(new URL("./", import.meta.url)); @@ -5047,6 +5049,15 @@ export class Debugger { }; return Response.json(snapshot, { headers: { "cache-control": "no-store" } }); } + if (pathname === openPath) { + // any page in the browser can post here, but only this server's may open files + if (request.method !== "POST" || request.headers.get("origin") !== `http://${request.headers.get("host")}`) { + return new Response(null, { status: 403 }); + } + // a terminal editor runs until it quits, which the page does not wait for + void subprocess.launchEditor(path.resolve(this.#graph.root, await request.text())).catch(log.error); + return new Response(null, { status: 204 }); + } const check = request.headers.has(pageHeader); const navigated = request.headers.get("sec-fetch-dest") === "document"; // 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"; import * as progress from "./progress.ts"; import * as repl from "./repl.ts"; import * as ansi from "./string/ansi.ts"; +import * as subprocess from "./subprocess.ts"; import * as ts from "./ts.ts"; import { AsyncLocalStorage } from "node:async_hooks"; diff --git a/lib/incremental/debugger.test.ts b/lib/incremental/debugger.test.ts index 3ad52c422f98497e7aa8fb58acfd55fa15055758..4d3430368774414e3dd0e125c0bc09b9817bbc57 100644 --- a/lib/incremental/debugger.test.ts +++ b/lib/incremental/debugger.test.ts @@ -91,6 +91,45 @@ test("the library is served as javascript, and only its typescript", async () => assert.equal(await (await get("/.clover/lib/%2e%2e/run.js")).text(), "from the site"); }); +test.skipIf(os.platform() === "win32")( + "a page opens a file in the editor, which only this server's pages may", + async () => { + const graph = new incremental.Graph({ db: null }); + const respond = new incremental.Debugger(graph).respond(async () => new Response("from the site")); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clover-editor-")); + const editor = path.join(dir, "code"); + fs.writeFileSync( + editor, + `#!${process.execPath}\nrequire("node:fs").writeFileSync(process.argv[1] + ".argv", JSON.stringify(process.argv.slice(2)));\n`, + { mode: 0o755 }, + ); + const open = (origin: string) => + respond( + new Request("http://localhost:3000/.clover/open", { + method: "POST", + headers: { host: "localhost:3000", origin }, + body: "lib/incremental.ts:12:4", + }), + { node: progress.nullNode, remoteAddress: "127.0.0.1" }, + ); + process.env.LAUNCH_EDITOR = editor; + try { + assert.equal((await open("http://elsewhere.example")).status, 403); + assert.equal((await open("http://localhost:3000")).status, 204); + while (!fs.existsSync(editor + ".argv")) await new Promise((resolve) => setTimeout(resolve, 10)); + // a relative path is from the project + assert.deepEqual(JSON.parse(fs.readFileSync(editor + ".argv", "utf8")), [ + "-r", + "-g", + `${path.join(graph.root, "lib/incremental.ts")}:12:4`, + ]); + } finally { + delete process.env.LAUNCH_EDITOR; + fs.rmSync(dir, { recursive: true, force: true }); + } + }, +); + // ## the socket test("a page hears of every build, its progress, and its errors", async () => { const debug = new incremental.Debugger(new incremental.Graph({ db: null })); @@ -351,7 +390,9 @@ async function connect(base: string, headers: Record) { } import assert from "node:assert/strict"; +import * as fs from "node:fs"; import * as net from "node:net"; +import * as os from "node:os"; import * as path from "node:path"; import * as consumers from "node:stream/consumers"; import * as url from "node:url"; diff --git a/lib/incremental/steps.test.ts b/lib/incremental/steps.test.ts index 75ccb45246e33c1999a1b11cc66c939236760b76..71d3f32d24dcd2046753c273d2927412c3729cb4 100644 --- a/lib/incremental/steps.test.ts +++ b/lib/incremental/steps.test.ts @@ -346,7 +346,7 @@ test("a step catching another step's error is cached against that error", async }); test("warnings fail the build without stopping the step", async () => { - await using p = await project({ "post.md": "teh cat" }); + await using p = await project({ "post.md": "the cat" }); let runs = 0; const graph = p.graph(); const site = () => @@ -354,16 +354,16 @@ test("warnings fail the build without stopping the step", async () => { ref.step(async function spellcheck(b) { runs++; const text = await b.readFile("post.md").text(); - const col = text.indexOf("teh"); + const col = text.indexOf("cat"); if (col !== -1) { - b.warn(Object.assign(new Error("misspelled 'the'"), { file: "post.md", line: 1, col: col + 1 })); + b.warn(Object.assign(new Error("flag 'cat'"), { file: "post.md", line: 1, col: col + 1 })); } return text.toUpperCase(); }).read()); for (const cached of [false, true]) { const error = await site().catch((error) => error) as incremental.BuildError; - assert.equal(error.value, "TEH CAT"); + assert.equal(error.value, "THE CAT"); const [lint] = error.errors as Array; assert.equal(lint!.message, "misspelled 'the'"); assert.equal(lint!.fatal, false); @@ -402,12 +402,12 @@ test("errors thrown from the same place are grouped, and identical ones merge", }); test("warnings about other places are said apart, even when reported from the same line", async () => { - await using p = await project({ "post.md": "teh cat teh" }); + await using p = await project({ "post.md": "a cat meow" }); const error = await build(p.graph(), (ref) => ref.step(async function spellcheck(b) { const text = await b.readFile("post.md").text(); - for (const match of text.matchAll(/teh/g)) { - b.warn(Object.assign(new Error("misspelled 'the'"), { file: "post.md", line: 1, col: match.index + 1 })); + for (const match of text.matchAll(/meow/g)) { + b.warn(Object.assign(new Error("flag 'meow'"), { file: "post.md", line: 1, col: match.index + 1 })); } }).read()).catch((error) => error) as incremental.BuildError; assert.deepEqual(error.errors.map((warning) => (warning as Error & { col: number }).col), [1, 9]); diff --git a/lib/incremental/visualization.ts b/lib/incremental/visualization.ts index bd59060d4589f270629455264baa37c08b8d9a28..7eaadfb776ca4d821057e52bc5e61d80082071ea 100644 --- a/lib/incremental/visualization.ts +++ b/lib/incremental/visualization.ts @@ -117,6 +117,15 @@ const states: Record failed: "#ff3b3b", }; +/** a problem or note that is open, and not folding away */ +const unfolded = "[open]:not(.folding)"; + +/** how a fold comes to rest, fast and then exponentially slower, like the graph's motion */ +const ease = `linear(${ + Array.from({ length: 17 }, (_, i) => ((1 - Math.exp(-6 * i / 16)) / (1 - Math.exp(-6))).toFixed(4)) +})`; +const easeMs = 360; + const tabButtons = tabs.map((name) => `` ).join(""); @@ -158,7 +167,7 @@ shadow.innerHTML = `
-`; +`; const find = (selector: string) => UNWRAP(shadow.querySelector(selector)); const button = find(".clover"); @@ -195,7 +204,7 @@ function render() { button.setAttribute("aria-expanded", String(visible)); const ago = last && string.formatDurationLetters(-Math.round((Date.now() - last.at) / 1000)); find(".tip .title").innerHTML = `${title(state)}${ago ? `, last build ${ago}` : ""}`; - find(".tip .line").textContent = status?.statusLine ?? ""; + find(".tip .status").textContent = status?.statusLine ?? ""; panel.hidden = !visible; panel.dataset.tab = tab; @@ -263,7 +272,7 @@ const fold = ``; function showFold() { - const open = shadow.querySelector("details.diagnostic[open]") !== null; + const open = shadow.querySelector(`details.diagnostic${unfolded}`) !== null; for (const button of shadow.querySelectorAll(".fold")) { button.classList.toggle("folded", !open); button.setAttribute("aria-label", open ? "fold every problem" : "unfold every problem"); @@ -291,10 +300,8 @@ function title(state: keyof typeof titles): string { function diagnostic(of: stack.Diagnostic): string { const head = (label: string, text: string) => `${string.escapeHtmlContent(label)}: ${string.escapeHtmlContent(text)}`; - const where = of.location - ? `${string.escapeHtmlContent((of.location.file ?? unknown) + positionOf(of.location))}` - : ""; - let html = `${head(of.label, of.message)}${where}`; + const where = of.location ? `${fileName(of.location)}` : ""; + let html = `${chevron}${head(of.label, of.message)}${where}`; if (of.location) html += excerpt(of.location, true); if (of.fields.length > 0) { const fields = of.fields.map(([name, json]) => @@ -304,7 +311,7 @@ function diagnostic(of: stack.Diagnostic): string { } for (const note of of.notes) { html += note.frames.length > 0 - ? `
${head("note", note.text)}` + ? `
${chevron}${head("note", note.text)}` + `
${note.frames.map((frame) => excerpt(frame, false)).join("")}
` : `

${head("note", note.text)}

` + `${note.location ? excerpt(note.location, false) : ""}
`; @@ -318,7 +325,7 @@ function diagnostic(of: stack.Diagnostic): string { const items = of.similar.map(({ message, from }) => `
  • ${string.escapeHtmlContent(message)} ${string.escapeHtmlContent(from)}
  • ` ); - html += `
    and ${of.similar.length} more like it` + html += `
    ${chevron}and ${of.similar.length} more like it` + `
      ${items.join("")}
    `; } return `
    ${html}
    `; @@ -326,6 +333,27 @@ function diagnostic(of: stack.Diagnostic): string { const unknown = "[unknown source]"; +/** what folds a problem or note, so the rest of its line can be selected */ +const chevron = + ``; + +/** fold or unfold, keeping what folds away in sight until it is gone */ +function setOpen(details: HTMLDetailsElement, open: boolean) { + if (details.matches(unfolded) === open) return; + const from = details.offsetHeight; + for (const animation of details.getAnimations()) animation.cancel(); + details.classList.toggle("folding", !open); + details.open = open; + const to = details.offsetHeight; + details.open = true; + const duration = matchMedia("(prefers-reduced-motion:reduce)").matches ? 0 : easeMs; + details.animate({ height: [`${from}px`, `${to}px`], overflow: ["clip", "clip"] }, { duration, easing: ease }) + .finished.then(() => { + details.classList.remove("folding"); + details.open = open; + }, () => {}); +} + /** where a place is in its file: `:line:col`, or an anchor in a url */ function positionOf({ file, line, col }: stack.Excerpt): string { if (!line) return ""; @@ -335,12 +363,7 @@ function positionOf({ file, line, col }: stack.Excerpt): string { /** a place and its code: the line it points at, and with `context`, the lines around it, faded */ function excerpt(place: stack.Excerpt, context: boolean): string { const { file, line, fn, code, first, span, internal } = place; - const url = file?.startsWith("https://") ? file + positionOf(place) : null; - // the source of a runtime's own code is a link away - const named = url - ? `${string.escapeHtmlContent(url)}` - : string.escapeHtmlContent(file ?? unknown) + `${positionOf(place)}`; - const where = named + (fn ? ` at ${string.escapeHtmlContent(fn)}` : ""); + const where = fileName(place) + (fn ? ` at ${string.escapeHtmlContent(fn)}` : ""); // blank lines around the line it points at say nothing const shown = code.map((text, i) => ({ text, n: first + i })) @@ -359,6 +382,17 @@ function excerpt(place: stack.Excerpt, context: boolean): string { + (lines.length > 0 ? `
    ${lines.join("")}
    ` : "") + ""; } +/** a place's file, which opens in the editor, or for a runtime's own code, links to its source */ +function fileName(place: stack.Excerpt): string { + if (place.file === null) return unknown; + const at = place.file + positionOf(place); + if (at.startsWith("https://")) { + return `${string.escapeHtmlContent(at)}`; + } + return ``; +} + /** a longer line, like minified code, shows this much around what it points at */ const widest = 160; @@ -1282,9 +1316,22 @@ dialog.addEventListener("cancel", (event) => { dismiss(); }); shadow.addEventListener("click", (event) => { - if (!(event.target as Element).closest(".fold")) return; - folded = shadow.querySelector("details.diagnostic[open]") !== null; - for (const problem of shadow.querySelectorAll("details.diagnostic")) problem.open = !folded; + const target = event.target as Element; + const file = target.closest("[data-open]")?.dataset.open; + if (file) return void fetch("/.clover/open", { method: "POST", body: file }); + const summary = target.closest("summary"); + if (summary) { + event.preventDefault(); + // a click from the keyboard has no `detail` + if ((event as MouseEvent).detail > 0 && !target.closest(".chevron")) return; + const details = summary.parentElement as HTMLDetailsElement; + setOpen(details, !details.matches(unfolded)); + } else if (target.closest(".fold")) { + folded = shadow.querySelector(`details.diagnostic${unfolded}`) !== null; + for (const problem of shadow.querySelectorAll("details.diagnostic")) setOpen(problem, !folded); + } else { + return; + } showFold(); }); // 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} @keyframes blink{50%{opacity:.2}} .live{position:fixed;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)} -.tip{display:none;top:8px;right:46px;max-width:300px;padding:5px 8px;border:1px solid #000;background:#fff; - color:#000;line-height:1.2} +.tip{display:none;top:8px;right:46px;max-width:min(300px,calc(100vw - 54px));padding:5px 8px;border:1px solid #000; + background:#fff;color:#000;line-height:1.2;overflow-wrap:anywhere} /* one stroke over the border's own, so its sides are as wide as the border and join it cleanly */ .tip::before{content:"";position:absolute;top:5px;left:100%;width:8px;height:18px; background:url("data:image/svg+xml,")} @@ -1388,7 +1435,7 @@ summary.head{display:block} .note>summary,.note>.head{color:#bbb} .colon{color:#999} .where{display:none;font-size:12px;line-height:1.4;color:#999;white-space:normal} -details:not([open])>summary>.where{display:block} +:is(details:not([open]),.folding)>summary>.where{display:block} /* the first problem leaves room for the button that folds them all */ .problems>details:first-of-type>summary{padding-right:26px} .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} figcaption{padding:3px 8px 0;font-size:12px;line-height:1.4;color:#bbb;overflow-wrap:anywhere} .pos{color:#888} figcaption a{color:inherit;text-decoration:none} -figcaption a:hover{text-decoration:underline} +figcaption a:hover,.path:hover{text-decoration:underline} +.path{all:unset;cursor:pointer} +.path:hover{background:none} .fn{color:#3ee6ff} .excerpt pre{margin:0;padding:1px 0 3px;overflow-x:auto;font-size:12px;line-height:1.45;color:#e6e6e6;tab-size:2} .line{display:block;padding-right:8px;white-space:pre} @@ -1424,10 +1473,13 @@ figcaption a:hover{text-decoration:underline} .fields dt{color:#999} .fields dt::after{content:":"} .fields dd{margin:0;overflow-wrap:anywhere} -summary{cursor:pointer;list-style:none;line-height:1.35} +summary{list-style:none;line-height:1.35} summary::-webkit-details-marker{display:none} -.note>summary::before{content:"\\25B8";display:inline-block;width:14px;color:#999} -.note[open]>summary::before{content:"\\25BE"} +.chevron{display:inline-block;width:14px;color:#999;cursor:pointer;user-select:none} +.chevron:hover{color:#fff} +.chevron svg{width:10px;height:10px;vertical-align:-1px;fill:none;stroke:currentColor;stroke-width:1.5; + transition:rotate ${easeMs}ms ${ease}} +${unfolded}>summary>.chevron svg{rotate:90deg} .frames{display:flex;flex-direction:column;gap:4px;margin-top:4px} .nested{padding-left:10px;border-left:2px solid #333} .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} #view-graph{position:relative} canvas{display:block;width:100%;height:min(60vh,480px);cursor:grab;touch-action:none} -.panel.max canvas{height:calc(100vh - 68px)} +.panel.max canvas{height:calc(100vh - 48px)} .legend{position:absolute;left:0;bottom:0;display:flex;flex-wrap:wrap;gap:2px 10px;padding:4px 6px; background:#000c;font-size:11px;color:#ccc;pointer-events:none} .legend i{display:inline-block;box-sizing:border-box;width:9px;height:9px;margin-right:4px;vertical-align:-1px} .point{position:absolute;width:max-content;max-width:320px;padding:4px 6px;background:#fff;color:#000;font-size:12px; line-height:1.2;overflow-wrap:anywhere;pointer-events:none} /* a build's progress floats over the graph, which shows through */ -.panel[data-tab=graph] .body{position:relative} +.panel[data-tab=graph] .body{position:relative;padding:0} .panel[data-tab=graph] .slot{position:absolute;top:10px;left:10px;right:10px;z-index:1;max-height:45%; overflow:hidden;padding:8px 10px;background:#000c;border:2px solid #999} -@media (prefers-reduced-motion:reduce){.clover.stale svg{animation:none}} +@media (prefers-reduced-motion:reduce){.clover.stale svg{animation:none}.chevron svg{transition:none}} `; } diff --git a/lib/log.ts b/lib/log.ts index 1cf57d1325bd0b51c5a397527ccc8f7d90e8d275..b27f32fc752d5c7b9df30f69eb0d25672b97a4d7 100644 --- a/lib/log.ts +++ b/lib/log.ts @@ -189,7 +189,7 @@ export function replaceGlobalWidgetHost(host: WidgetHost) { global.widget = { frozen: false, source: stack.capture()[0]?.file - ?? ("untracable call to replaceGlobalWidgetHost in " + import.meta.url), + ?? ("untraceable call to replaceGlobalWidgetHost in " + import.meta.url), host, version: 0, }; @@ -237,7 +237,7 @@ export function startWidget( } /** - * no built-in prefix, formatting, or newlline. ensures the text does not interweave. + * no built-in prefix, formatting, or newline. ensures the text does not interweave. * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}. */ export function writeOutput(text: string) { @@ -406,10 +406,10 @@ export interface TerminalWidgetHostOptions { * will patch `process.std{out,err}` to ensure write calls properly get a * draw lock. * - * this lock can be cleared by deactiving all widgets, or by calling + * this lock can be cleared by deactivating all widgets, or by calling * {@linkcode getDrawLock} to force `@clo/lib` to stop drawing. * - * currently, this lock must be able to be synchronously aquired at any + * currently, this lock must be able to be synchronously acquired at any * point. if you desire an async locking function, please contact me so we * can design how it would work. */ @@ -446,12 +446,12 @@ export interface TerminalWidgetHostOptions { */ export interface TerminalLock { /** - * recieves ANSI escape sequences for interactive data, as well as error log + * receives ANSI escape sequences for interactive data, as well as error log * content from `writeError` (should flush immediately). the interactive * stream and the error stream are the same: stderr. */ writeInteractive(text: string): void; - /** recieves log content from `write` (pre-buffered; should flush immediately) */ + /** receives log content from `write` (pre-buffered; should flush immediately) */ writeOutput(text: string): void; /** * subscribe to the terminal size. the callback must fire synchronously @@ -492,7 +492,7 @@ export interface WidgetHost { /** * what does this widget host support? * - color: ANSI escape sequences are displayed and not stripped - * - widget: `startWidget` can meaninglyful display the widget + * - widget: `startWidget` can meaningfully display the widget */ capabilities: ReadonlyArray<"widget" | "color">; } @@ -1860,7 +1860,7 @@ export function defaultNodeProcessWidgetHost( // non-node runtimes will typically implement console in a way that // doesn't use `node:process`, so it must also get patched. this is - // okay because the lock is re-enterant. + // okay because the lock is reentrant. function patchSyncMethod( fn: (this: T, ...args: A) => void, ) { diff --git a/lib/meta.ts b/lib/meta.ts index a1c94df18d1edade520f2f64ab8b5f483c6c1aab..2ea3f1c46fe10c08a0b9fa4995df3efef264f005 100644 --- a/lib/meta.ts +++ b/lib/meta.ts @@ -6,7 +6,7 @@ export interface Meta { description?: string | null; /** automatically added for static renders from the 'pages' folders. */ canonical?: string | null; - /** add ``. Object keys are interpretted as + /** add ``. Object keys are interpreted as * mime types if they contain a slash, otherwise seen as an alternative language. */ alternates?: Alternate[] | Record | null; diff --git a/lib/progress.ts b/lib/progress.ts index a840fde7b241738ae032464619a4c03d750319a1..f8e758443b33504d9530837316406b4d09fc472d 100644 --- a/lib/progress.ts +++ b/lib/progress.ts @@ -58,7 +58,7 @@ export function start(text: string, opts?: StartOptions): Node { /** * a reference point to create sub-items. this interface is satisfied by * - the `progress` module itself - * - a node recieved by `p.start("label")` + * - a node received by `p.start("label")` * - a headless progress runner from `progress.headless()` * * 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 { * } * ``` * - * sometimes it makes sense to have an API recieve `Node` instead of `Ref`, + * sometimes it makes sense to have an API receive `Node` instead of `Ref`, * specifically if the caller should have some control over the node name. * * ```ts @@ -90,7 +90,7 @@ export interface Ref { } /** - * control for a progress nooe. all fields are primed with setters to trigger + * control for a progress node. all fields are primed with setters to trigger * ui dispatch automatically. to make tools that use `progress.ts` more * modular to a custom progress implementation, many fields are optional. */ @@ -102,7 +102,7 @@ export interface Node extends Ref, Disposable { /** * number of items completed. by default this is a count of items, but the * `units` property can be passed to `start` to change how the `value` is - * interpretted when displaying this progress node. + * interpreted when displaying this progress node. */ value: number; /** @@ -127,7 +127,7 @@ export interface Node extends Ref, Disposable { estimatedTime?: number | null; /** * default `true`. `false` will hides a bar + printing total. this is useful - * for preseving auto-end behavior of `inc` without showing the total. + * for preserving auto-end behavior of `inc` without showing the total. */ showTotal?: boolean; /** @@ -153,14 +153,14 @@ export interface StartOptions { /** when set above `0`, it is shown with a progress bar. */ total?: number | undefined | null; /** - * presets for common formattings of `value` and `total`. + * presets for common formats of `value` and `total`. * @default "count" */ units?: "count" | "bytes" | "percent"; /** * customize how an estimate is given for progress bars. to make * `Node.estimatedTime` mutable, pass `false` here. - * @default `null`, which is equivilent to `new progress.Ema()` + * @default `null`, which is equivalent to `new progress.Ema()` */ estimateCompletion?: EstimationAlgorithm | boolean | null; /** @@ -175,7 +175,7 @@ export interface StartOptions { hidden?: boolean | undefined | null; /** * default `true`. `false` will hides a bar + printing estimate, - * but preseving auto-end behavior of `inc` + * but preserving auto-end behavior of `inc` */ showTotal?: boolean | undefined | null; /** specify sorting for children */ @@ -1192,7 +1192,7 @@ export const contentType = "application/x-clover-progress"; /** * opaque but JSON-compatible serialized state, either containing the entire * progress tree or just a delta update. this data structure always be future - * and backwards compatbile with changes to this library. if a breaking change + * and backwards compatible with changes to this library. if a breaking change * is made, the APIs in this file will be renamed. * * for details on the format, read the source of {@linkcode encodeEventStream} or diff --git a/lib/queue.ts b/lib/queue.ts index 8465fccddc1e85d4bedbb77a6d9838765b3d746b..1e1590e40695b0c87505824e4f5d58dcb37cb3b0 100644 --- a/lib/queue.ts +++ b/lib/queue.ts @@ -27,8 +27,8 @@ export interface Job { priority?: number; /** If not `true`, the given AbortSignal will never abort. */ cancelable?: boolean; - /** @default false */ - causeCancelations?: boolean; + /** @default true */ + causeCancellations?: boolean; } export class PriorityQueue { @@ -65,7 +65,7 @@ export class PriorityQueue { if (this.canRun(job)) { this.startJob(job); } else { - if (job.causeCancelations ?? true) { + if (job.causeCancellations ?? true) { // Attempt to cancel other jobs to allow pushing this one up. let remain = this.coresRemain; for ( @@ -112,7 +112,7 @@ export class PriorityQueue { .finally(() => { this.coresRemain += job.cores; const index = active.indexOf(job); - if (index === -1) throw new Error("fyuc"); + if (index === -1) throw new Error("completed job is missing from the active queue"); active.splice(index, 1); if (this.coresRemain % 1 !== 0) { this.coresRemain = active.reduce( diff --git a/lib/readme.changes.md b/lib/readme.changes.md index 6a4b7f5390ad4e950914bc1c65a8a51413b4ce88..51563f5d38e8c329b1efe8ed05c75807d14b0ec8 100644 --- a/lib/readme.changes.md +++ b/lib/readme.changes.md @@ -5,6 +5,7 @@ ### breaking - `subprocess` is rewritten around web streams. +- `queue.Job`'s cancellation option is renamed to `causeCancellations`. ### features @@ -18,6 +19,9 @@ pages this machine asks for: each build's status, progress, and errors, and the graph of its steps. - `stream.lines` reads a byte stream as lines of text. +- `subprocess.launchEditor` shows a file in the editor the user has open, at a + line and column, ported from vite's `launch-editor`. +- `subprocess.spawn`'s `shell` option runs a command through the system shell. - `subprocess`'s `progress` option hands a `progress.Ref` to a child process, which becomes the global root of `progress` there. the child's global log is written by the parent as well. `progress.childEnv` and `progress.attachChild` @@ -86,7 +90,7 @@ - `meta.toTags` creates an object compatible with TanStack Router - the `extra` object now takes an array of React-like elements among some other supported forms. -- `log` and `progress` have recieved so many bug-fix changes that it is worth +- `log` and `progress` have received so many bug-fix changes that it is worth ensuring your application edge cases still work as expected. - `string.formatByteSize` formats differently. @@ -159,7 +163,7 @@ deprecations without removals: - `progress.ts` node gains `node.log.write("word ");` to write a message without a newline. - `async.delay` handles timers longer than 23 days and `Infinity`. -- `Lru.revive` recieves bug fixes. this function previously didn't really work. +- `Lru.revive` receives bug fixes. this function previously didn't really work. - `ansi` gets more cursor control constants ## v3 diff --git a/lib/readme.md b/lib/readme.md index 67d5a5fa7eaa7ab06b4a02241d3221bdee299ec6..058ae1a96979e8f11c1a56481b3f24b10c151816 100644 --- a/lib/readme.md +++ b/lib/readme.md @@ -31,7 +31,7 @@ depend on each other. there are no external dependencies; code is easy to audit. - `queue` - priority queue with rescheduling - `repl` - interactive prompt for long running programs - `stream` - ReadableStream helpers -- `string` - trival helpers +- `string` - trivial helpers - `string/ansi` - cursor and color constants - `subprocess` - rebuilt api for `node:child_process` - `subprocess/ffmpeg` - spawn and parse progress output @@ -72,7 +72,7 @@ named as single words so they can be easily imported. ## versioning -these libraries are published as is, **without versioning or maintainance +these libraries are published as is, **without versioning or maintenance guarantees**. every version is a breaking major release. please upgrade your libraries with intent, considering api changes in [readme.changes.md]. individual modules may annotate their intended api and usage stability. diff --git a/lib/stream.test.ts b/lib/stream.test.ts index 7e102cc1a046aede942df77e28bca2d0fe42f268..815638e0a2c34914f62297ee002eea8b6386b559 100644 --- a/lib/stream.test.ts +++ b/lib/stream.test.ts @@ -256,8 +256,8 @@ describe("lines", () => { }); test("a character split between chunks", async () => { - const bytes = string.encodeUtf8("caf\u00e9\n"); - assert.deepEqual(await collect([bytes.subarray(0, 4), bytes.subarray(4)]), ["caf\u00e9"]); + const bytes = string.encodeUtf8("cat\u00e9\n"); + assert.deepEqual(await collect([bytes.subarray(0, 4), bytes.subarray(4)]), ["cat\u00e9"]); }); test("leaving early cancels the stream", async () => { diff --git a/lib/string/ansi.ts b/lib/string/ansi.ts index 64982f49f8b47360816b1dfb46c8fbc5fbbdbcb6..180e56af35cdef8204949cee908eb01584dc0617 100644 --- a/lib/string/ansi.ts +++ b/lib/string/ansi.ts @@ -231,7 +231,7 @@ export function mergeStyles(code: string): string { */ export function debugAnsi(text: string): string { // deno-fmt-ignore - const verbaitim: (keyof typeof self)[] = [ + const verbatim: (keyof typeof self)[] = [ "fgCyan", "fgBlue", "bgReset", @@ -278,7 +278,7 @@ export function debugAnsi(text: string): string { "cursorSave", "cursorRestore", ]; - for (const key of verbaitim) { + for (const key of verbatim) { text = text.replaceAll( String(self[key]), style(key.includes("cursor") ? fgBlue : fgCyan, `\${${key}}`), diff --git a/lib/subprocess.test.ts b/lib/subprocess.test.ts index c2b318f744d603b5ebf6cb0484344b05c86c3fd9..f88f74c80f9f67c384a150662f3b776f698a2bf2 100644 --- a/lib/subprocess.test.ts +++ b/lib/subprocess.test.ts @@ -227,12 +227,104 @@ describe("progress", () => { }); }); +// windows runs editors through cmd.exe, which cannot execute these stubs +describe.skipIf(os.platform() === "win32")("launchEditor", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "clover-editor-")); + // a directory with a space in it, since an editor command is shell-quoted + const stubDir = path.join(root, "editor stubs"); + fs.mkdirSync(stubDir); + afterAll(() => fs.rmSync(root, { recursive: true, force: true })); + + /** an executable standing in for an editor, which records the arguments it is given */ + function stub(name: string, exitCode = 0) { + const file = path.join(stubDir, name); + fs.writeFileSync( + file, + `#!${process.execPath}\n` + + `require("node:fs").writeFileSync(process.argv[1] + ".argv", JSON.stringify(process.argv.slice(2)));\n` + + `process.exit(${exitCode});\n`, + { mode: 0o755 }, + ); + return { + command: `'${file}'`, + argv: (): string[] => JSON.parse(fs.readFileSync(file + ".argv", "utf8")), + }; + } + + const file = path.resolve("lib/subprocess.ts"); + + test("vscode takes a position after the file name", async () => { + const code = stub("code"); + await subprocess.launchEditor(file, { editor: code.command, line: 12, column: 4 }); + assert.deepEqual(code.argv(), ["-r", "-g", `${file}:12:4`]); + }); + + test("$LAUNCH_EDITOR is used, and the position may be in the file name", async () => { + const code = stub("Code.cmd"); + process.env.LAUNCH_EDITOR = code.command; + try { + await subprocess.launchEditor(`${file}:7`); + } finally { + delete process.env.LAUNCH_EDITOR; + } + assert.deepEqual(code.argv(), ["-r", "-g", `${file}:7:1`]); + }); + + test("jetbrains editors take flags, including the 64-bit builds", async () => { + const idea = stub("idea64"); + await subprocess.launchEditor(`${file}:12:4`, { editor: idea.command }); + assert.deepEqual(idea.argv(), ["--line", "12", "--column", "4", file]); + }); + + test("vim moves its cursor with a command", async () => { + const vim = stub("vim"); + await subprocess.launchEditor(file, { editor: vim.command, line: 12, column: 4 }); + assert.deepEqual(vim.argv(), [`+call cursor(12, 4)`, file]); + }); + + test("arguments in the editor command come before the file", async () => { + const code = stub("code"); + await subprocess.launchEditor(file, { editor: `${code.command} --wait`, line: 2 }); + assert.deepEqual(code.argv(), ["--wait", "-r", "-g", `${file}:2:1`]); + }); + + test("a file:// url is a path", async () => { + const zed = stub("zed"); + await subprocess.launchEditor(`${url.pathToFileURL(file)}:3:9`, { editor: zed.command }); + assert.deepEqual(zed.argv(), [`${file}:3:9`]); + }); + + test("an unknown editor named by the user gets the position as arguments", async () => { + const mine = stub("fancy-editor"); + await subprocess.launchEditor(file, { editor: mine.command, line: 12, column: 4 }); + assert.deepEqual(mine.argv(), [file, "12", "4"]); + + await subprocess.launchEditor(file, { editor: mine.command }); + assert.deepEqual(mine.argv(), [file]); + }); + + test("an editor that is not installed explains itself", async () => { + await assert.rejects(subprocess.launchEditor(file, { editor: "clover-lib-missing-editor" }), { + message: "could not open subprocess.ts in clover-lib-missing-editor: not found in $PATH", + }); + }); + + test("an editor that fails is reported with its exit status", async () => { + const broken = stub("code", 3); + const error = await subprocess.launchEditor(file, { editor: broken.command }).catch((error) => error); + assert.equal(exception.message(error), "could not open subprocess.ts in code"); + assert.ok(error.cause instanceof subprocess.ExitError); + assert.equal(error.cause.code, 3); + }); +}); + import { strict as assert } from "node:assert"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import * as process from "node:process"; -import { describe, test } from "vitest"; +import * as url from "node:url"; +import { afterAll, describe, test } from "vitest"; import { UNWRAP } from "./assert.ts"; import * as async from "./async.ts"; import * as exception from "./exception.ts"; diff --git a/lib/subprocess.ts b/lib/subprocess.ts index 7d84b60a978dd055960a3cdb2f255283b4b16466..845b662dd418db56b658aeadc79b3c5988acf58c 100644 --- a/lib/subprocess.ts +++ b/lib/subprocess.ts @@ -33,7 +33,7 @@ export async function execute( */ export function spawn(cmd: Command, opts?: O): Subprocess { const { stdin = "ignore", stdout = "inherit", stderr = "inherit" }: Options = opts ?? {}; - const { cwd, env, signal, ipc, progress: handoff }: Options = opts ?? {}; + const { cwd, env, signal, ipc, shell, progress: handoff }: Options = opts ?? {}; signal?.throwIfAborted(); const stdio: Array = [stdin, stdout, stderr]; @@ -42,6 +42,7 @@ export function spawn(cmd: Command, opts?: O): Subproce const child = child_process.spawn(cmd[0], cmd.slice(1), { cwd, + shell, env: { ...process.env, ...env, ...handoff && progress.ipcChildEnvironment(progressFd) }, stdio, }); @@ -116,6 +117,8 @@ export interface Options { stdout?: Stdio; /** @default "inherit" */ stderr?: Stdio; + /** run `cmd` through the system shell, which does not escape what it is given */ + shell?: boolean; /** open a channel for messages with a child that is node.js too, where it is `process` */ ipc?: boolean; /** for a subprocess using `@clo/lib/process`, setup progress IPC. */ @@ -245,12 +248,265 @@ interface ProcessLookup { command: string; } +/** + * open a file in the text editor that is already in front of the user, at a + * line and column, a port of vite's `launch-editor`. + * + * `file` is a path, a path with `:line:column` appended, or a `file://` url. + * resolves once the editor process exits, which is immediate for the editors + * that hand the file to a window already open. + * + * what happens to a path that does not exist is up to the editor; most open an + * empty buffer for it. + */ +export async function launchEditor(file: string, options: EditorOptions = {}): Promise { + const { fileName, line: fileLine, column: fileColumn } = parsePosition(file); + const line = options.line ?? fileLine; + const column = options.column ?? fileColumn ?? 1; + const windows = os.platform() === "win32"; + + // cmd.exe would happily run an editor off of a remote share + if (windows && fileName.startsWith("\\\\")) { + throw new Error("cannot open a UNC path on windows"); + } + + const specified = options.editor ?? process.env.LAUNCH_EDITOR; + const [editor, ...args] = specified ? splitCommand(specified) : await runningEditor(); + if (!editor) throw new Error(`cannot open ${path.basename(fileName)}: no editor found, set $EDITOR`); + + // an editor this port has no arguments for gets a position only when the user + // named it, since the wrong flags make editors create files or hang on input. + const position = line === undefined ? null : positionArgs(editor, fileName, line, column) + ?? (specified ? [fileName, `${line}`, `${column}`] : null); + args.push(...position ?? [fileName]); + + const inTerminal = terminalEditors.has(editorName(editor)); + // two editors sharing the terminal would both read from its input + if (inTerminal && currentEditor) { + currentEditor.replaced = true; + await currentEditor.proc[Symbol.asyncDispose](); + } + + const proc = spawn(windows ? [windowsLaunchCommand(editor, args)] : [editor, ...args], { + stdin: "inherit", + shell: windows, + }); + const entry: typeof currentEditor = inTerminal ? { proc } : null; + if (entry) currentEditor = entry; + try { + await proc.wait(); + } catch (error) { + if (entry?.replaced) return; + const why = exception.code(error) === "ENOENT" ? ": not found in $PATH" : ""; + throw new Error(`could not open ${path.basename(fileName)} in ${editorName(editor)}${why}`, { cause: error }); + } finally { + if (currentEditor === entry) currentEditor = null; + } +} + +export interface EditorOptions { + /** + * the editor to run, as a shell command so that arguments may be included. + * defaults to `$LAUNCH_EDITOR`, then the editor this machine has running, + * then `$VISUAL` or `$EDITOR`. an editor named here that this module has no + * arguments for is passed the line and column as two more arguments. + */ + editor?: string; + /** 1-based, taking precedence over a `:line` in the file name */ + line?: number; + /** 1-based, defaulting to the first column */ + column?: number; +} + +/** the editor holding the terminal's input, which only one of them may */ +let currentEditor: { proc: Subprocess; replaced?: boolean } | null = null; + +/** `file.ts:12:4`, where the line and column are optional, or a `file://` url */ +function parsePosition(file: string) { + if (file.startsWith("file://")) file = url.fileURLToPath(file); + const [suffix, line, column] = /:(\d+)(?::(\d+))?$/.exec(file) ?? []; + return { + fileName: path.resolve(suffix ? file.slice(0, -suffix.length) : file), + line: line ? Number(line) : undefined, + column: column ? Number(column) : undefined, + }; +} + +/** splits `code -w` or `'/Volumes/My Disk/zed' --new`, quotes included */ +function splitCommand(command: string): string[] { + return command.match(/'[^']*'|"[^"]*"|\S+/g) + ?.map((arg) => arg.replace(/^(['"])(.*)\1$/s, "$2")) ?? []; +} + +/** the name an editor is known by, however it was spelled or installed */ +function editorName(editor: string): string { + // `.exe`/`.cmd` and jetbrains' `64` suffix both name the editor before them + return path.basename(editor).toLowerCase().replace(/\.(exe|cmd|bat)$/, "").replace(/64$/, ""); +} + +/** arguments placing an editor's cursor at a position, or `null` if unknown */ +function positionArgs(editor: string, fileName: string, line: number, column: number): string[] | null { + switch (argStyles.get(editorName(editor))) { + case "vscode": + return ["-r", "-g", `${fileName}:${line}:${column}`]; + case "suffix": + return [`${fileName}:${line}:${column}`]; + case "jetbrains": + return ["--line", `${line}`, "--column", `${column}`, fileName]; + case "mate": + return ["--line", `${line}`, fileName]; + case "vim": + return [`+call cursor(${line}, ${column})`, fileName]; + case "plus": + return [`+${line}`, fileName]; + case "emacs": + return [`+${line}:${column}`, fileName]; + case "notepad++": + return [`-n${line}`, `-c${column}`, fileName]; + default: + return null; + } +} + +/** + * cmd.exe would expand `&`, `|`, and friends out of a file name before the + * editor sees it, so they are escaped with `^`. `%var%` is expanded before the + * escapes are consumed and a line break starts another command: neither can be + * written safely, and a path containing one is refused instead. + */ +function windowsLaunchCommand(editor: string, args: string[]): string { + if (args.some((arg) => /[\r\n%]/.test(arg))) { + throw new Error(`cannot open a path containing a line break or '%' on windows`); + } + return [editor, ...args.map((arg) => arg.replace(/[&|<>,;=^]/g, "^$&"))] + // an escape has to be quoted, and the quotes around it escaped as well + .map((arg) => arg.includes("^") ? `^"${arg}^"` : arg.includes(" ") ? `"${arg}"` : arg) + .join(" "); +} + +/** + * the editor this machine has open, found in the process list, falling back to + * the classic environment variables. the result is a command, since an editor + * in `$EDITOR` may carry arguments. + */ +async function runningEditor(): Promise { + try { + const platform = os.platform(); + if (platform === "darwin") { + const processes = (await execute(["ps", "x", "-o", "comm="])).split("\n"); + for (const [app, cli] of macosEditors) { + // an app outside of /Applications matches too, and runs from where it is + const running = processes.find((name) => name.endsWith(app)); + if (running) return [cli || running]; + } + } else if (platform === "linux") { + const command = ["ps", "x", "--no-heading", "-o", "comm", "--sort=comm"] as const; + const processes = new Set((await execute(command)).split("\n").map((l) => l.trim())); + for (const [name, cli] of linuxEditors) { + if (processes.has(name) || processes.has(name + ".sh")) return [cli || name]; + } + } else if (platform === "win32") { + const query = "select executablepath from win32_process where executablepath is not null"; + const processes = await execute([ + "powershell", + "-NoProfile", + "-Command", + `[Console]::OutputEncoding=[Text.Encoding]::UTF8;` + + `Get-CimInstance -Query '${query}' | ForEach-Object { $_.ExecutablePath }`, + ]); + for (const exe of processes.split("\r\n").map((line) => line.trim())) { + if (windowsEditors.has(path.win32.basename(exe))) return [exe]; + } + } + } catch { + // without a process list there is nothing to detect + } + const fallback = process.env.VISUAL || process.env.EDITOR; + return fallback ? splitCommand(fallback) : []; +} + +/** editor name to the shape of the arguments that open a position in it */ +const argStyles = new Map( + ( // deno-fmt-ignore + "vscode#code,code - insiders,codium,vscodium,cursor,trae,antigravity\n" + + "suffix#atom,subl,sublime,sublime_text,wstorm,charm,zed\n" + + "jetbrains#appcode,clion,idea,phpstorm,pycharm,rubymine,webstorm,goland,rider\n" + + "mate#mate,rmate,mine\n" + + "vim#vim,mvim\n" + + "plus#joe,gvim\n" + + "emacs#emacs,emacsclient\n" + + "notepad++#notepad++" + ).split("\n").flatMap((group) => { + const [style, editors] = group.split("#") as [string, string]; + return editors.split(",").map((editor) => [editor, style] as const); + }), +); + +/** editors that take over the currentEditor they are started in */ +const terminalEditors = new Set("vi vim nvim nano emacs joe helix hx micro kak".split(" ")); + +/** + * the tail of the binary macos names a running editor by, to the command that + * opens a file in it. an app that launches itself has no command here, so the + * binary that is running is used, wherever it was installed. + */ +const macosEditors = new Map( + ( // deno-fmt-ignore + "/Atom.app/Contents/MacOS/Atom#atom\n" + + "/Sublime Text.app/Contents/MacOS/Sublime Text#/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl\n" + + "/Sublime Text.app/Contents/MacOS/sublime_text#/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl\n" + + "/Visual Studio Code.app/Contents/MacOS/Code#code\n" + + "/Visual Studio Code.app/Contents/MacOS/Electron#code\n" + + "/Visual Studio Code - Insiders.app/Contents/MacOS/Code - Insiders#code-insiders\n" + + "/Visual Studio Code - Insiders.app/Contents/MacOS/Electron#code-insiders\n" + + "/VSCodium.app/Contents/MacOS/Electron#codium\n" + + "/Cursor.app/Contents/MacOS/Cursor#cursor\n" + + "/Trae.app/Contents/MacOS/Electron#trae\n" + + "/Antigravity.app/Contents/MacOS/Electron#antigravity\n" + + "/Zed.app/Contents/MacOS/zed#zed\n" + + "/MacVim.app/Contents/MacOS/MacVim#mvim\n" + + "/AppCode.app/Contents/MacOS/appcode\n" + + "/CLion.app/Contents/MacOS/clion\n" + + "/IntelliJ IDEA.app/Contents/MacOS/idea\n" + + "/IntelliJ IDEA Ultimate.app/Contents/MacOS/idea\n" + + "/IntelliJ IDEA Community Edition.app/Contents/MacOS/idea\n" + + "/PhpStorm.app/Contents/MacOS/phpstorm\n" + + "/PyCharm.app/Contents/MacOS/pycharm\n" + + "/PyCharm CE.app/Contents/MacOS/pycharm\n" + + "/RubyMine.app/Contents/MacOS/rubymine\n" + + "/WebStorm.app/Contents/MacOS/webstorm\n" + + "/GoLand.app/Contents/MacOS/goland\n" + + "/Rider.app/Contents/MacOS/rider" + ).split("\n").map((line) => line.split("#") as [string, string | undefined]), +); + +/** process name linux names a running editor by, to the command that opens a file in it */ +const linuxEditors = new Map( + ( // deno-fmt-ignore + "atom code-insiders code codium vscodium cursor trae antigravity" + + " zed emacs gvim vim sublime_text#subl" + + " idea phpstorm pycharm rubymine webstorm goland rider" + ).split(" ").map((entry) => entry.split("#") as [string, string | undefined]), +); + +/** windows has no command to open a file in an editor, so the binary is run */ +const windowsEditors = new Set( + ( // deno-fmt-ignore + "Code.exe,Code - Insiders.exe,VSCodium.exe,Cursor.exe,Trae.exe,Antigravity.exe,zed.exe" + + ",atom.exe,sublime_text.exe,notepad++.exe" + + ",clion.exe,clion64.exe,idea.exe,idea64.exe,phpstorm.exe,phpstorm64.exe,pycharm.exe,pycharm64.exe" + + ",rubymine.exe,rubymine64.exe,webstorm.exe,webstorm64.exe,goland.exe,goland64.exe,rider.exe,rider64.exe" + ).split(","), +); + import * as child_process from "node:child_process"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import * as process from "node:process"; import * as stream from "node:stream"; +import * as url from "node:url"; import { ASSERT } from "./assert.ts"; +import * as exception from "./exception.ts"; import * as progress from "./progress.ts"; import * as string from "./string.ts"; diff --git a/package-lock.json b/package-lock.json index 0ca2deb4a2c31b15a9402a277d02fc4550149ff0..e979dddb284a67983af1be17e0c0a855bab4ed70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "@marko/compiler": "5.39.62", "@types/node": "24.x", "@types/wawoff2": "^1.0.2", + "cspell-lib": "10.4.0", "dashjs": "5.0.3", "dprint": "0.53.1", "drizzle-orm": "1.0.0-rc.4", @@ -58,6 +59,539 @@ } } }, + "node_modules/@cspell/cspell-bundled-dicts": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-10.4.0.tgz", + "integrity": "sha512-ey23dut7NdnOsQQinQhROFZUh4LvLhJnnZCiq7y+KXMngdqXnIAZuEnnwXk3IfdOX+tycDC8OuMssLXDJ3Fnxw==", + "license": "MIT", + "dependencies": { + "@cspell/dict-ada": "^4.1.1", + "@cspell/dict-al": "^1.1.1", + "@cspell/dict-aws": "^4.0.17", + "@cspell/dict-bash": "^4.2.3", + "@cspell/dict-companies": "^3.2.13", + "@cspell/dict-cpp": "^7.1.3", + "@cspell/dict-cryptocurrencies": "^5.0.5", + "@cspell/dict-csharp": "^4.0.8", + "@cspell/dict-css": "^4.1.2", + "@cspell/dict-dart": "^2.3.2", + "@cspell/dict-data-science": "^2.0.16", + "@cspell/dict-django": "^4.1.7", + "@cspell/dict-docker": "^1.1.17", + "@cspell/dict-dotnet": "^5.0.15", + "@cspell/dict-elixir": "^4.0.8", + "@cspell/dict-en_us": "^4.4.41", + "@cspell/dict-en-common-misspellings": "^2.2.5", + "@cspell/dict-en-gb-mit": "^3.1.30", + "@cspell/dict-filetypes": "^3.0.18", + "@cspell/dict-flutter": "^1.1.1", + "@cspell/dict-fonts": "^4.0.6", + "@cspell/dict-fsharp": "^1.1.1", + "@cspell/dict-fullstack": "^3.2.9", + "@cspell/dict-gaming-terms": "^1.1.3", + "@cspell/dict-git": "^3.1.1", + "@cspell/dict-golang": "^6.0.29", + "@cspell/dict-google": "^1.0.9", + "@cspell/dict-haskell": "^4.0.6", + "@cspell/dict-html": "^4.0.16", + "@cspell/dict-html-symbol-entities": "^4.0.5", + "@cspell/dict-java": "^5.0.12", + "@cspell/dict-julia": "^1.1.1", + "@cspell/dict-k8s": "^1.0.13", + "@cspell/dict-kotlin": "^1.1.1", + "@cspell/dict-latex": "^5.1.0", + "@cspell/dict-lorem-ipsum": "^4.0.5", + "@cspell/dict-lua": "^4.0.8", + "@cspell/dict-makefile": "^1.0.5", + "@cspell/dict-markdown": "^2.0.18", + "@cspell/dict-monkeyc": "^1.1.2", + "@cspell/dict-node": "^5.0.9", + "@cspell/dict-npm": "^5.2.54", + "@cspell/dict-php": "^4.1.3", + "@cspell/dict-powershell": "^5.0.17", + "@cspell/dict-public-licenses": "^2.0.17", + "@cspell/dict-python": "^4.5.2", + "@cspell/dict-r": "^2.1.1", + "@cspell/dict-ruby": "^5.1.4", + "@cspell/dict-rust": "^4.1.2", + "@cspell/dict-scala": "^5.0.11", + "@cspell/dict-shell": "^1.2.0", + "@cspell/dict-software-terms": "^5.4.8", + "@cspell/dict-sql": "^2.2.1", + "@cspell/dict-svelte": "^1.0.7", + "@cspell/dict-swift": "^2.0.6", + "@cspell/dict-terraform": "^1.1.4", + "@cspell/dict-typescript": "^3.2.3", + "@cspell/dict-vue": "^3.0.5", + "@cspell/dict-zig": "^1.0.0" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/cspell-performance-monitor": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-performance-monitor/-/cspell-performance-monitor-10.4.0.tgz", + "integrity": "sha512-0hCqI8pR0waFKKOR3SZ4VtWddx7HZTr0v7MPCPG0IpaqMzzaibrXn9AtFCe+9T1+UDQrM109YKS+yr0c6xVvMQ==", + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/cspell-pipe": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-10.4.0.tgz", + "integrity": "sha512-cXHgST9ySpECyiE5ZK7TcwBqrCb3h9X49j/lSDcWNAVp7GkwnPfcdylH+Zq/VfYMlHad24z8CNXd5H91O9DhEA==", + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/cspell-resolver": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-10.4.0.tgz", + "integrity": "sha512-uyI9qX8hagtcFtRTElYGXXyVhhnVxPBSf1Ltds9AlU9cktPOFYeTXQOfBR+Dpt9dCruzX29ZJbd3hR1i5yryZg==", + "license": "MIT", + "dependencies": { + "global-directory": "^5.0.0" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/cspell-service-bus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-10.4.0.tgz", + "integrity": "sha512-IZzx+EWADkN5xbFrW+VS+JQ52+w5tWaZNxDgGa+0Mv79hks6wNa2Vcnce+9vGcBJYgA24VcuAfekI38tYzcDtw==", + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/cspell-types": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-10.4.0.tgz", + "integrity": "sha512-LTOrZcDSHj5BkeNSvimuV3AMF7gWjxzXUdQ7zYT/h/57/5flHuE6NPKSXS69ty8vJM0WrVh35L5aDxnCPfDzXA==", + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/dict-ada": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-ada/-/dict-ada-4.1.1.tgz", + "integrity": "sha512-E+0YW9RhZod/9Qy2gxfNZiHJjCYFlCdI69br1eviQQWB8yOTJX0JHXLs79kOYhSW0kINPVUdvddEBe6Lu6CjGQ==", + "license": "MIT" + }, + "node_modules/@cspell/dict-al": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-al/-/dict-al-1.1.1.tgz", + "integrity": "sha512-sD8GCaZetgQL4+MaJLXqbzWcRjfKVp8x+px3HuCaaiATAAtvjwUQ5/Iubiqwfd1boIh2Y1/3EgM3TLQ7Q8e0wQ==", + "license": "MIT" + }, + "node_modules/@cspell/dict-aws": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@cspell/dict-aws/-/dict-aws-4.0.17.tgz", + "integrity": "sha512-ORcblTWcdlGjIbWrgKF+8CNEBQiLVKdUOFoTn0KPNkAYnFcdPP0muT4892h7H4Xafh3j72wqB4/loQ6Nti9E/w==", + "license": "MIT" + }, + "node_modules/@cspell/dict-bash": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-bash/-/dict-bash-4.2.3.tgz", + "integrity": "sha512-ljUZoKHbDqw5Sx0qpL2qTUlmkmr+vhZH/sCNrNaBZKTbdgiswErSnIF1jRbGmEitJNxHRHWsuZyVgnTGfVO1Yw==", + "license": "MIT", + "dependencies": { + "@cspell/dict-shell": "1.2.0" + } + }, + "node_modules/@cspell/dict-companies": { + "version": "3.2.13", + "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.13.tgz", + "integrity": "sha512-mBJeYzCkiiWgNQxKg7zyQOpFPwcXO4IAzXGbOEFfcQ4Q0G5Fa7Kqd8z+gvnvSK7NPWKJaFmiApC6I0cka02qmQ==", + "license": "MIT" + }, + "node_modules/@cspell/dict-cpp": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-7.1.3.tgz", + "integrity": "sha512-2d2MSh+C+xzzdqjLwtt40TdXWDR/pK0q1Q9L5zC+1EPSCIZxlN7CHbhlXWpl0JqUSdy2xaWaWucwnoGznMTneA==", + "license": "MIT" + }, + "node_modules/@cspell/dict-cryptocurrencies": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-cryptocurrencies/-/dict-cryptocurrencies-5.0.5.tgz", + "integrity": "sha512-R68hYYF/rtlE6T/dsObStzN5QZw+0aQBinAXuWCVqwdS7YZo0X33vGMfChkHaiCo3Z2+bkegqHlqxZF4TD3rUA==", + "license": "MIT" + }, + "node_modules/@cspell/dict-csharp": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-csharp/-/dict-csharp-4.0.8.tgz", + "integrity": "sha512-qmk45pKFHSxckl5mSlbHxmDitSsGMlk/XzFgt7emeTJWLNSTUK//MbYAkBNRtfzB4uD7pAFiKgpKgtJrTMRnrQ==", + "license": "MIT" + }, + "node_modules/@cspell/dict-css": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.1.2.tgz", + "integrity": "sha512-+ylGoKdwZ2sVOCOnU2Eq5wDZx+RaVX3HoKyNHGGsFvhSw6IidQ6tH/mAPKBDofViHJoWCPNlklE0lTr6MDG3QA==", + "license": "MIT" + }, + "node_modules/@cspell/dict-dart": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-dart/-/dict-dart-2.3.2.tgz", + "integrity": "sha512-sUiLW56t9gfZcu8iR/5EUg+KYyRD83Cjl3yjDEA2ApVuJvK1HhX+vn4e4k4YfjpUQMag8XO2AaRhARE09+/rqw==", + "license": "MIT" + }, + "node_modules/@cspell/dict-data-science": { + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-data-science/-/dict-data-science-2.0.16.tgz", + "integrity": "sha512-M72mxv5asuAnORurz4iXRJ+Tw9XBq6eu7D2Ne7biP0Z1RciKGNxXWu9JycA/KlVvK1hAlKj/fANlXhuEWpXKFg==", + "license": "MIT" + }, + "node_modules/@cspell/dict-django": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-django/-/dict-django-4.1.7.tgz", + "integrity": "sha512-94LIxiF/StBTxDkmBhi1eoOX9D/B7gk6pabBvIZFh0gmREAU6ESqU2Wv/RfGARdueMQMPfOJka+vbtL4yKv0oA==", + "license": "WTFPL" + }, + "node_modules/@cspell/dict-docker": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@cspell/dict-docker/-/dict-docker-1.1.17.tgz", + "integrity": "sha512-OcnVTIpHIYYKhztNTyK8ShAnXTfnqs43hVH6p0py0wlcwRIXe5uj4f12n7zPf2CeBI7JAlPjEsV0Rlf4hbz/xQ==", + "license": "MIT" + }, + "node_modules/@cspell/dict-dotnet": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.15.tgz", + "integrity": "sha512-j2MUKPlzMeDy/mnb1EjmtkX8e6VzZdw60Qyf6vPlp7WZhUwwp2LBlKOWx7+hASjP6k4a0nDpmn77G+4upxvbZA==", + "license": "MIT" + }, + "node_modules/@cspell/dict-elixir": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-elixir/-/dict-elixir-4.0.8.tgz", + "integrity": "sha512-CyfphrbMyl4Ms55Vzuj+mNmd693HjBFr9hvU+B2YbFEZprE5AG+EXLYTMRWrXbpds4AuZcvN3deM2XVB80BN/Q==", + "license": "MIT" + }, + "node_modules/@cspell/dict-en_us": { + "version": "4.4.41", + "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.41.tgz", + "integrity": "sha512-PsfGqN/dSSPpaSl1vKaPI4DHAmgbFITON398YUEvoduDM1mWXnDOL+qBQ/ZjpWs5+aaShPq5c1vpJg8fuQFOQw==", + "license": "MIT" + }, + "node_modules/@cspell/dict-en-common-misspellings": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-common-misspellings/-/dict-en-common-misspellings-2.2.5.tgz", + "integrity": "sha512-c1bj+HXT8fafxrBT/nmVLwr7ILVomgVSGlWsb2wjJYanqvLJHXkCVmr0sM97F9FJeWQFIgwrRi5MWSYbNQAQ6A==", + "license": "CC BY-SA 4.0" + }, + "node_modules/@cspell/dict-en-gb-mit": { + "version": "3.1.30", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.30.tgz", + "integrity": "sha512-wmsPqPrO+GRfD8iA2jSKzRdEVd46ZbnDttRWM8RC4a8DkeiIanTtIgqX5V23jJibSgpsRVAtY+ktTw2nOK/Yvw==", + "license": "MIT" + }, + "node_modules/@cspell/dict-filetypes": { + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.18.tgz", + "integrity": "sha512-yU7RKD/x1IWmDLzWeiItMwgV+6bUcU/af23uS0+uGiFUbsY1qWV/D4rxlAAO6Z7no3J2z8aZOkYIOvUrJq0Rcw==", + "license": "MIT" + }, + "node_modules/@cspell/dict-flutter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-flutter/-/dict-flutter-1.1.1.tgz", + "integrity": "sha512-UlOzRcH2tNbFhZmHJN48Za/2/MEdRHl2BMkCWZBYs+30b91mWvBfzaN4IJQU7dUZtowKayVIF9FzvLZtZokc5A==", + "license": "MIT" + }, + "node_modules/@cspell/dict-fonts": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.6.tgz", + "integrity": "sha512-aR/0csY01dNb0A1tw/UmN9rKgHruUxsYsvXu6YlSBJFu60s26SKr/k1o4LavpHTQ+lznlYMqAvuxGkE4Flliqw==", + "license": "MIT" + }, + "node_modules/@cspell/dict-fsharp": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-fsharp/-/dict-fsharp-1.1.1.tgz", + "integrity": "sha512-imhs0u87wEA4/cYjgzS0tAyaJpwG7vwtC8UyMFbwpmtw+/bgss+osNfyqhYRyS/ehVCWL17Ewx2UPkexjKyaBA==", + "license": "MIT" + }, + "node_modules/@cspell/dict-fullstack": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.9.tgz", + "integrity": "sha512-diZX+usW5aZ4/b2T0QM/H/Wl9aNMbdODa1Jq0ReBr/jazmNeWjd+PyqeVgzd1joEaHY+SAnjrf/i9CwKd2ZtWQ==", + "license": "MIT" + }, + "node_modules/@cspell/dict-gaming-terms": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-gaming-terms/-/dict-gaming-terms-1.1.3.tgz", + "integrity": "sha512-hLGzd/f+KX5B/5YxAXuQi4DYEfu6IKudtNFO/Nima3JLXD+8Xn+d0q6VdaBxkWVhGYlzow9o+dce+S9mNtgpMg==", + "license": "MIT" + }, + "node_modules/@cspell/dict-git": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-git/-/dict-git-3.1.1.tgz", + "integrity": "sha512-XvuGVYj9YEu4EzntqyhH8XlTRyy57C6j8oxkIbUQfR2UYzxZGAP77XHl7qY7eC/40r/haDwnBaccyFR6BRmUCg==", + "license": "MIT" + }, + "node_modules/@cspell/dict-golang": { + "version": "6.0.29", + "resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-6.0.29.tgz", + "integrity": "sha512-EZCgfxrr5lPnYQLmSvB6YtpfoDFt2KKxAD4ppq2xGSIgDFXAF7Mv4doiXIFUQaynCBIEfAMl3f/uDfrIUo4fyQ==", + "license": "MIT" + }, + "node_modules/@cspell/dict-google": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-google/-/dict-google-1.0.9.tgz", + "integrity": "sha512-biL65POqialY0i4g6crj7pR6JnBkbsPovB2WDYkj3H4TuC/QXv7Pu5pdPxeUJA6TSCHI7T5twsO4VSVyRxD9CA==", + "license": "MIT" + }, + "node_modules/@cspell/dict-haskell": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-haskell/-/dict-haskell-4.0.6.tgz", + "integrity": "sha512-ib8SA5qgftExpYNjWhpYIgvDsZ/0wvKKxSP+kuSkkak520iPvTJumEpIE+qPcmJQo4NzdKMN8nEfaeci4OcFAQ==", + "license": "MIT" + }, + "node_modules/@cspell/dict-html": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.16.tgz", + "integrity": "sha512-9B6/Cpb5YVcYgsEAlKudun1yd4ONllYS/ZibKLYea2apPR+l2vQdARnPrP9kTqa7NUNfRKl7m9Fb6k9rjimnow==", + "license": "MIT" + }, + "node_modules/@cspell/dict-html-symbol-entities": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.5.tgz", + "integrity": "sha512-429alTD4cE0FIwpMucvSN35Ld87HCyuM8mF731KU5Rm4Je2SG6hmVx7nkBsLyrmH3sQukTcr1GaiZsiEg8svPA==", + "license": "MIT" + }, + "node_modules/@cspell/dict-java": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-java/-/dict-java-5.0.12.tgz", + "integrity": "sha512-qPSNhTcl7LGJ5Qp6VN71H8zqvRQK04S08T67knMq9hTA8U7G1sTKzLmBaDOFhq17vNX/+rT+rbRYp+B5Nwza1A==", + "license": "MIT" + }, + "node_modules/@cspell/dict-julia": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-julia/-/dict-julia-1.1.1.tgz", + "integrity": "sha512-WylJR9TQ2cgwd5BWEOfdO3zvDB+L7kYFm0I9u0s9jKHWQ6yKmfKeMjU9oXxTBxIufhCXm92SKwwVNAC7gjv+yA==", + "license": "MIT" + }, + "node_modules/@cspell/dict-k8s": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@cspell/dict-k8s/-/dict-k8s-1.0.13.tgz", + "integrity": "sha512-ELGkS13k7K/NEfVimBSrxVTfqXvOF/Kvxj4I62YxRm8bvHbfoXgrGaOx28lPiNRz+dmu+yYtvuXbnURKtYbC6g==", + "license": "MIT" + }, + "node_modules/@cspell/dict-kotlin": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-kotlin/-/dict-kotlin-1.1.1.tgz", + "integrity": "sha512-J3NzzfgmxRvEeOe3qUXnSJQCd38i/dpF9/t3quuWh6gXM+krsAXP75dY1CzDmS8mrJAlBdVBeAW5eAZTD8g86Q==", + "license": "MIT" + }, + "node_modules/@cspell/dict-latex": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-5.1.0.tgz", + "integrity": "sha512-qxT4guhysyBt0gzoliXYEBYinkAdEtR2M7goRaUH0a7ltCsoqqAeEV8aXYRIdZGcV77gYSobvu3jJL038tlPAw==", + "license": "MIT" + }, + "node_modules/@cspell/dict-lorem-ipsum": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-lorem-ipsum/-/dict-lorem-ipsum-4.0.5.tgz", + "integrity": "sha512-9a4TJYRcPWPBKkQAJ/whCu4uCAEgv/O2xAaZEI0n4y1/l18Yyx8pBKoIX5QuVXjjmKEkK7hi5SxyIsH7pFEK9Q==", + "license": "MIT" + }, + "node_modules/@cspell/dict-lua": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-lua/-/dict-lua-4.0.8.tgz", + "integrity": "sha512-N4PkgNDMu9JVsRu7JBS/3E/dvfItRgk9w5ga2dKq+JupP2Y3lojNaAVFhXISh4Y0a6qXDn2clA6nvnavQ/jjLA==", + "license": "MIT" + }, + "node_modules/@cspell/dict-makefile": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-makefile/-/dict-makefile-1.0.5.tgz", + "integrity": "sha512-4vrVt7bGiK8Rx98tfRbYo42Xo2IstJkAF4tLLDMNQLkQ86msDlYSKG1ZCk8Abg+EdNcFAjNhXIiNO+w4KflGAQ==", + "license": "MIT" + }, + "node_modules/@cspell/dict-markdown": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.18.tgz", + "integrity": "sha512-KLRSwVrwKDz4n7bl2XQjzvaBZbGgx5QKkP5Ok1b24xaO3TpXsLhAbAn1mItvFlI2tF6Rkt83iYjQcYppywuf5Q==", + "license": "MIT", + "peerDependencies": { + "@cspell/dict-css": "^4.1.2", + "@cspell/dict-html": "^4.0.16", + "@cspell/dict-html-symbol-entities": "^4.0.5", + "@cspell/dict-typescript": "^3.2.3" + } + }, + "node_modules/@cspell/dict-monkeyc": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-monkeyc/-/dict-monkeyc-1.1.2.tgz", + "integrity": "sha512-4VzwdSUVqyUVmAKAzMkVwVa7AkvSi1l1OHdQr58MWq4VR4NQDO8sVQSibd2PMxruXTkxJt9UwVQVHgaaVTiV1Q==", + "license": "MIT" + }, + "node_modules/@cspell/dict-node": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-node/-/dict-node-5.0.9.tgz", + "integrity": "sha512-hO+ga+uYZ/WA4OtiMEyKt5rDUlUyu3nXMf8KVEeqq2msYvAPdldKBGH7lGONg6R/rPhv53Rb+0Y1SLdoK1+7wQ==", + "license": "MIT" + }, + "node_modules/@cspell/dict-npm": { + "version": "5.2.54", + "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.54.tgz", + "integrity": "sha512-7jQPRZL1XnZ0HPk5iYAq5WpJ5dpgKHRDBl9aB6uQQBJdo90mCF4bjPGSVl2HrWgx+IXlafiyx/QNO2XII/hgYg==", + "license": "MIT" + }, + "node_modules/@cspell/dict-php": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-4.1.3.tgz", + "integrity": "sha512-41oKzl7rLVrrVZA/Gs0ESnP6KliEX7c+IwaaNmOL3PAoHasFDVEmIFeENIzuDdM12UcHT+MlLkAm3Qw/lS/ERA==", + "license": "MIT" + }, + "node_modules/@cspell/dict-powershell": { + "version": "5.0.17", + "resolved": "https://registry.npmjs.org/@cspell/dict-powershell/-/dict-powershell-5.0.17.tgz", + "integrity": "sha512-khOoIdBgKRk2scnr7888pf70yJ1Tr3RGdnYcAN0MJ2vtaNRKoOTCFc13gXUkNDHwEyhMI1h9oz3KsT180F2amw==", + "license": "MIT" + }, + "node_modules/@cspell/dict-public-licenses": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@cspell/dict-public-licenses/-/dict-public-licenses-2.0.17.tgz", + "integrity": "sha512-Asdumgg3oZ4ki//b2a+beiVIsNZA5thim4t4v3lTDLY/6a8VAPmTjzC3Y2t0x7ywC/bzAhi1KW/fg8JlwHDgig==", + "license": "MIT" + }, + "node_modules/@cspell/dict-python": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.5.2.tgz", + "integrity": "sha512-CFcTDGN/ycn45WXC5ZZ06pG09WbGKMpPm1rkMuqMP98/jwjw3iiK8c0izWRd8x027n1rOZ9kJ8f9Sd+bQ22HJg==", + "license": "MIT", + "dependencies": { + "@cspell/dict-data-science": "^2.0.16" + } + }, + "node_modules/@cspell/dict-r": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-r/-/dict-r-2.1.1.tgz", + "integrity": "sha512-71Ka+yKfG4ZHEMEmDxc6+blFkeTTvgKbKAbwiwQAuKl3zpqs1Y0vUtwW2N4b3LgmSPhV3ODVY0y4m5ofqDuKMw==", + "license": "MIT" + }, + "node_modules/@cspell/dict-ruby": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.1.4.tgz", + "integrity": "sha512-Slw1dLY/pW5liJ5Fle+MIPphIdleZkIM6STDoXCN7sy298GfbfT34sJTcSZcXzZJVXiCEAvlLp+s1YNr6DuWNg==", + "license": "MIT" + }, + "node_modules/@cspell/dict-rust": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-rust/-/dict-rust-4.1.2.tgz", + "integrity": "sha512-O1FHrumYcO+HZti3dHfBPUdnDFkI+nbYK3pxYmiM1sr+G0ebOd6qchmswS0Wsc6ZdEVNiPYJY/gZQR6jfW3uOg==", + "license": "MIT" + }, + "node_modules/@cspell/dict-scala": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-5.0.11.tgz", + "integrity": "sha512-+D59847Y61KZ0MBoXJzBMtOXfFhezEb6ShjvJlL7bnryyy6iBwAfozbz7OJAe+KrEpYm+GlVV7Wy4FmJSgxBag==", + "license": "MIT" + }, + "node_modules/@cspell/dict-shell": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-shell/-/dict-shell-1.2.0.tgz", + "integrity": "sha512-PVctvT22lJ49niMiakO8xieY7ELCAzjSqhejWR7bAMb5AZ9F4WDEs+XdGMnoVHWeXq7K5rcepLPmEJb+37zzIw==", + "license": "MIT" + }, + "node_modules/@cspell/dict-software-terms": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.4.8.tgz", + "integrity": "sha512-7Fv/jgRghpcEcvpmrRR54gPp1l85pPLmlMua74hjhMJoWklAp905V742XR6u5HwvGRH7aBTW9euNsJULav1E6A==", + "license": "MIT" + }, + "node_modules/@cspell/dict-sql": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-sql/-/dict-sql-2.2.1.tgz", + "integrity": "sha512-qDHF8MpAYCf4pWU8NKbnVGzkoxMNrFqBHyG/dgrlic5EQiKANCLELYtGlX5auIMDLmTf1inA0eNtv74tyRJ/vg==", + "license": "MIT" + }, + "node_modules/@cspell/dict-svelte": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-svelte/-/dict-svelte-1.0.7.tgz", + "integrity": "sha512-hGZsGqP0WdzKkdpeVLBivRuSNzOTvN036EBmpOwxH+FTY2DuUH7ecW+cSaMwOgmq5JFSdTcbTNFlNC8HN8lhaQ==", + "license": "MIT" + }, + "node_modules/@cspell/dict-swift": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-swift/-/dict-swift-2.0.6.tgz", + "integrity": "sha512-PnpNbrIbex2aqU1kMgwEKvCzgbkHtj3dlFLPMqW1vSniop7YxaDTtvTUO4zA++ugYAEL+UK8vYrBwDPTjjvSnA==", + "license": "MIT" + }, + "node_modules/@cspell/dict-terraform": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@cspell/dict-terraform/-/dict-terraform-1.1.4.tgz", + "integrity": "sha512-Ere42ilvMFvQA4GlcN0OKlruMPR6EsvaB+iTHzj2xc+NJGRK64V7yApUcWrOrSgTiM/vhWXPIsK3OMfiAiNdmA==", + "license": "MIT" + }, + "node_modules/@cspell/dict-typescript": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", + "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", + "license": "MIT" + }, + "node_modules/@cspell/dict-vue": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-vue/-/dict-vue-3.0.5.tgz", + "integrity": "sha512-Mqutb8jbM+kIcywuPQCCaK5qQHTdaByoEO2J9LKFy3sqAdiBogNkrplqUK0HyyRFgCfbJUgjz3N85iCMcWH0JA==", + "license": "MIT" + }, + "node_modules/@cspell/dict-zig": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-zig/-/dict-zig-1.0.0.tgz", + "integrity": "sha512-XibBIxBlVosU06+M6uHWkFeT0/pW5WajDRYdXG2CgHnq85b0TI/Ks0FuBJykmsgi2CAD3Qtx8UHFEtl/DSFnAQ==", + "license": "MIT" + }, + "node_modules/@cspell/dynamic-import": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-10.4.0.tgz", + "integrity": "sha512-scSwtJDqpRT/Kqzo64XR8OWzXYfdpp9Yneq99Drhqm5gXXI2BAlgjzhBnMGKa+x7eIJZVM2+D2uFeDI2xQYM2w==", + "license": "MIT", + "dependencies": { + "@cspell/url": "10.4.0", + "import-meta-resolve": "^4.2.0" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/filetypes": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-10.4.0.tgz", + "integrity": "sha512-ez0MyFxnpYN9eMkH8vGGseQPsCRwD7ZlsR+ivQ2rPx04CX7ctp015nWv71P7m+T6V05AsyAwyEGZP9vrTlB4Ig==", + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/rpc": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@cspell/rpc/-/rpc-10.4.0.tgz", + "integrity": "sha512-tX1YLQYPQz+p73U5OTLmOxIrsmRkpaaKrPnfrVIpK5yv3erWE8ELVZD0zK01rursbL1rW3gyOv0ShHlmoE5VNA==", + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/strong-weak-map": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-10.4.0.tgz", + "integrity": "sha512-y8+Hk90mA3UY4HYEWBJokvNcixQOKkRDLEr+yXSYyOJnt6MhAu8D7N567HjEVNj0e7oirXl62wpNglPfo7qAcQ==", + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/url": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@cspell/url/-/url-10.4.0.tgz", + "integrity": "sha512-8NvDv8h3eaxejAvD+crMsSEgGq6LiunfS6iD2TBAcDqskNsIpN43AMHNNRf7T7Fd/3Y413nQDBH4RmsHcNsiqg==", + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, "node_modules/@dprint/darwin-arm64": { "version": "0.53.1", "resolved": "https://registry.npmjs.org/@dprint/darwin-arm64/-/darwin-arm64-0.53.1.tgz", @@ -1135,6 +1669,12 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "license": "MIT" + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -1230,6 +1770,19 @@ "integrity": "sha512-eNk3TRV+xQMJ1PEj0FQGY8KD4m0GPxT487XJ+Iftm7mVa9WpPFDMWqPt+46buiP5j5Wzqe5oMIhqBcAeKfygSA==", "license": "MIT" }, + "node_modules/comment-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz", + "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==", + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/complain": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/complain/-/complain-1.6.1.tgz", @@ -1245,6 +1798,125 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/cspell-config-lib": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-10.4.0.tgz", + "integrity": "sha512-1P0xmWIlGqwsHf8he9BWNdqV2bvRBkxCv5ctj3oHBraRFS6CPjdJBptyjuM0dgTwxmxPFcuWhTG8miTvFMje5g==", + "license": "MIT", + "dependencies": { + "@cspell/cspell-types": "10.4.0", + "comment-json": "^5.0.0", + "smol-toml": "^1.9.1", + "yaml": "^2.9.1" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/cspell-dictionary": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-10.4.0.tgz", + "integrity": "sha512-FwCQBzqOxjAg+GhymrY288As9DmJqHVrkAr++0tCQ1wWFVw2Nt1W3cCKn9W987Dr+giSuNREdQFpGbQ/cHluTg==", + "license": "MIT", + "dependencies": { + "@cspell/cspell-performance-monitor": "10.4.0", + "@cspell/cspell-pipe": "10.4.0", + "@cspell/cspell-types": "10.4.0", + "cspell-trie-lib": "10.4.0", + "fast-equals": "^6.1.1" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/cspell-glob": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-10.4.0.tgz", + "integrity": "sha512-XnctlpRbhln7dTmJyISw7HdsXM38CW+S7Ygm5kkAEixSyceim8RJfRj2rhtofL7B6Ra6YdRmJnm2/PWDOIVzLw==", + "license": "MIT", + "dependencies": { + "@cspell/url": "10.4.0", + "picomatch": "^4.0.7" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/cspell-grammar": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-10.4.0.tgz", + "integrity": "sha512-zWxN9g0oh2tzJDM3C11prpsqF2nTw5AHpXtOslTm3D5d4ZL4BfBHRj5PFxbz3Px4fKz/KptPhxSP5yGDCNtjJg==", + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "10.4.0", + "@cspell/cspell-types": "10.4.0" + }, + "bin": { + "cspell-grammar": "bin.mjs" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/cspell-io": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-10.4.0.tgz", + "integrity": "sha512-EunMcHZbgA7PYj6172t2nPFVVWo0L7xi4awIhMay27c3vYGiA+ajowIBtIdy2MLOTSqL+XOfcjkuahXh8L8BzQ==", + "license": "MIT", + "dependencies": { + "@cspell/cspell-service-bus": "10.4.0", + "@cspell/url": "10.4.0" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/cspell-lib": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-10.4.0.tgz", + "integrity": "sha512-wfqmn19pfEUjWy7UJ4xPeRwyb/LFXHxFVJ6maX87OLRuSE9fTP+cr74VANpKycd/rieuB2ffKta7l38n+Xhj/Q==", + "license": "MIT", + "dependencies": { + "@cspell/cspell-bundled-dicts": "10.4.0", + "@cspell/cspell-performance-monitor": "10.4.0", + "@cspell/cspell-pipe": "10.4.0", + "@cspell/cspell-resolver": "10.4.0", + "@cspell/cspell-types": "10.4.0", + "@cspell/dynamic-import": "10.4.0", + "@cspell/filetypes": "10.4.0", + "@cspell/rpc": "10.4.0", + "@cspell/strong-weak-map": "10.4.0", + "@cspell/url": "10.4.0", + "cspell-config-lib": "10.4.0", + "cspell-dictionary": "10.4.0", + "cspell-glob": "10.4.0", + "cspell-grammar": "10.4.0", + "cspell-io": "10.4.0", + "cspell-trie-lib": "10.4.0", + "env-paths": "^4.0.0", + "gensequence": "^8.0.8", + "import-fresh": "^4.0.1", + "resolve-from": "^5.0.0", + "vscode-languageserver-textdocument": "^1.0.15", + "vscode-uri": "^3.2.0", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/cspell-trie-lib": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-10.4.0.tgz", + "integrity": "sha512-3APfmYOrYEcoiDjupm3JsxLWvqa6IbJ76CqUTuIgXQH2EK0nOJ5SRKSTNP6AplDtalCs0o6VllbiRX6S81g8yw==", + "license": "MIT", + "engines": { + "node": ">=22.18.0" + }, + "peerDependencies": { + "@cspell/cspell-types": "10.4.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1520,6 +2192,21 @@ } } }, + "node_modules/env-paths": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", + "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==", + "license": "MIT", + "dependencies": { + "is-safe-filename": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-stack-parser": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", @@ -1575,6 +2262,19 @@ "@esbuild/win32-x64": "0.25.5" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1608,6 +2308,15 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-equals": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.2.1.tgz", + "integrity": "sha512-s7KFkLPAAbIDGTh+nVYhjTiwKGvgMXAUG5vwzg8vQKj7NSRZ4eEGExBQ4rq9V90fTYVWd1cb/Wr4ffCNBmLQMQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -1648,6 +2357,30 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensequence": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/gensequence/-/gensequence-8.0.8.tgz", + "integrity": "sha512-omMVniXEXpdx/vKxGnPRoO2394Otlze28TyxECbFVyoSpZ9H3EO7lemjcB12OpQJzRW4e5tt/dL1rOxry6aMHg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/global-directory": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", + "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", + "license": "MIT", + "dependencies": { + "ini": "6.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/harfbuzzjs": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-0.10.1.tgz", @@ -1706,6 +2439,28 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/import-fresh": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-4.0.1.tgz", + "integrity": "sha512-/XFeXYgkuWgbtqPC7qPKvciXTvhtz7WMNQKQsAM1Kz+j0I5OWJDrybfiya6QtHF9oEvPkLwTHmC9+4OR/MmfIA==", + "license": "MIT", + "engines": { + "node": ">=22.15" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imsc": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/imsc/-/imsc-1.1.5.tgz", @@ -1715,6 +2470,15 @@ "sax": "1.2.1" } }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -1749,6 +2513,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-safe-filename": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz", + "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2161,9 +2937,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -2341,6 +3117,18 @@ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "license": "ISC" }, + "node_modules/smol-toml": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", + "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -2695,6 +3483,12 @@ } } }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.15.tgz", + "integrity": "sha512-Gsfr9S8LU3S6885cS+NaleasJ0n3V5y/SOTqHCifQiXWFw3G9w9/YItDfwHrlCz+dSZWWZ8ilYVMcJe7Sz7Sxw==", + "license": "MIT" + }, "node_modules/vscode-oniguruma": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz", @@ -2707,6 +3501,12 @@ "integrity": "sha512-rkvG4SraZQaPSN/5XjwKswdU0OP9MF28QjrYzUBbhb8QyG3ljB1Ky996m++jiI7KdiAP2CkBiQZd9pqEDTClqA==", "license": "MIT" }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "license": "MIT" + }, "node_modules/warp10": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/warp10/-/warp10-2.1.0.tgz", @@ -2741,6 +3541,33 @@ "engines": { "node": ">=8" } + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/package.json b/package.json index f71fb3ba630bc1636b5f77a834d7d70d49dd0940..3b14f0608452d422a67a4cb31d53eddd0e46df3d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "@marko/compiler": "5.39.62", "@types/node": "24.x", "@types/wawoff2": "^1.0.2", + "cspell-lib": "10.4.0", "dashjs": "5.0.3", "dprint": "0.53.1", "drizzle-orm": "1.0.0-rc.4", diff --git a/readme.md b/readme.md index 609e095c531c5d009ab6c758234b5af85a2e3b5b..bfc047fac8fbfe5dd5b4083c0e5df281352edb3b 100644 --- a/readme.md +++ b/readme.md @@ -33,6 +33,10 @@ minimum system requirements: my primary development machine is a Dell Inspiron 7348 (Core i7) with Windows 7 + # copy the site's databases. without `CLOVER_SOT_KEY` in `.env`, the + # copies hold what the site shows anyone. + node run download-db + # setup and run the site locally, watching for changes. node run diff --git a/src/backend.ts b/src/backend.ts index ac35cd7b5ae86ca18bd778797e2dd48d5d75bce5..9fc459145c595ecdf19e6b0bd09ccc0f44d14684 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -13,6 +13,7 @@ app.route("", blog.app); app.route("", qa.app); app.route("", fileViewer.app); app.route("", friendAuth.app); +app.route("", databases.app); app.route("", extra.app); // Asset middleware has least precedence @@ -67,6 +68,7 @@ import { trimTrailingSlash } from "hono/trailing-slash"; import * as admin from "./admin.ts"; import * as extra from "./backend-extra.ts"; import * as blog from "./blog/backend.ts"; +import * as databases from "./databases.ts"; import * as fileViewer from "./file-viewer/backend.ts"; import "./file-viewer/sync.ts"; import * as friendAuth from "./friend-auth.ts"; diff --git a/src/bin/deploy.ts b/src/bin/deploy.ts index d3c89aab7ed86c57eda463a4f4a06ee983400dd7..d48d1f020bc9ccac194871778f1591a61dde0a5d 100644 --- a/src/bin/deploy.ts +++ b/src/bin/deploy.ts @@ -16,8 +16,8 @@ export async function main(graph: incremental.Graph) { `${remote} runs node.js ${stdout.trim()}, and this build's cache is for ${process.version}`, ); - await download("cache.sqlite"); - await download("questions.sqlite"); + // a site that is down can't hand out its databases, and a deploy may be what brings it back + await downloadDb.main().catch((error) => console.warn("building with the local databases:", error)); // any error stops the deploy, including the ones a build can still be served with await graph.incrementalBuild({ progress, buildAndWriteOutput }); @@ -67,33 +67,11 @@ export async function main(graph: incremental.Graph) { ], { stdin: "inherit" }).wait(); } -/** - * replace the local copy of one of the server's databases. the server has it - * open, with its latest writes in a write-ahead log that a copy of the file - * alone would miss, so the server writes a consistent copy first. - */ -async function download(name: string) { - const snapshot = `/tmp/deploy-${name}`; - const vacuum = `new (require("node:sqlite").DatabaseSync)(".clover/${name}", { readOnly: true })` - + `.prepare("vacuum into ?").run("${snapshot}")`; - await subprocess.execute(["ssh", remote, `rm -f ${snapshot} && cd paperclover.net && ${node}/node -e '${vacuum}'`]); - await rsync.spawn({ - cwd, - args: ["-a", `${remote}:${snapshot}`, `.clover/${name}.next`], - progress: progress.start(`download ${name}`), - }); - // the server's /tmp is memory - await subprocess.execute(["ssh", remote, `rm ${snapshot}`]); - // a write-ahead log beside the old copy would be applied to the new one - for (const log of ["-wal", "-shm"]) fs.rmSync(`.clover/${name}${log}`, { force: true }); - fs.renameSync(`.clover/${name}.next`, `.clover/${name}`); -} - import { Path } from "#sitegen/path"; import { ASSERT } from "@clo/lib/assert"; import type * as incremental from "@clo/lib/incremental"; import * as progress from "@clo/lib/progress"; import * as subprocess from "@clo/lib/subprocess"; -import * as fs from "node:fs"; import * as rsync from "../file-viewer/rsync.ts"; import { buildAndWriteOutput } from "../paperclover.net.ts"; +import * as downloadDb from "./download-db.ts"; diff --git a/src/bin/download-db.ts b/src/bin/download-db.ts index cb60a961ca3336fe4b5d5d8ce4fb2078b80ea6b5..eb55512d36cf35f9006c56fc39c954c1c18069e9 100644 --- a/src/bin/download-db.ts +++ b/src/bin/download-db.ts @@ -1,21 +1,27 @@ -// Pull production state for local development. cache.sqlite comes from the -// source of truth's /db route (no ssh needed); questions.sqlite is still -// rsync'd off the web node since the source of truth does not own it yet. +// Saves copies of the site's databases into `.clover`, which is all that a +// clone of this repository lacks to build the site. With CLOVER_SOT_KEY, the +// copies are whole; without it, they hold what the site shows anyone. +const site = "https://paperclover.net"; +const token = process.env.CLOVER_SOT_KEY ?? null; + export async function main() { - await sync.revalidate(); - await rsync.spawn({ - cwd: Path.resolve("."), - args: [ - "-a", - "--progress", - "clo@paperclover.net:~/paperclover.net/.clover/questions.sqlite", - ".clover/questions.sqlite", - ], - progress: progress.start("download questions state"), - }); + for (const name of ["cache.sqlite", "questions.sqlite"]) { + using node = progress.start(`download ${name}`, { units: "bytes" }); + const response = await fetch(`${site}/db/${name}`, { headers: token ? { Authorization: token } : {} }); + if (!response.ok || !response.body) { + throw new Error(`${site}/db/${name} responded ${response.status} ${response.statusText}`); + } + node.total = Number(response.headers.get("Content-Length")); + await getDatabase(name).restore(response.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + node.value += chunk.byteLength; + controller.enqueue(chunk); + }, + }), + )); + } } -import { Path } from "#sitegen/path"; +import { getDatabase } from "#sitegen/drizzle"; import * as progress from "@clo/lib/progress"; -import * as rsync from "../file-viewer/rsync.ts"; -import * as sync from "../file-viewer/sync.ts"; diff --git a/src/blog/drafts/webdev/marko-intro.markodown b/src/blog/drafts/webdev/marko-intro.markodown index 9afe753c9cec12f30f9b114f39fba6eaea97ab7b..1f4a92b3275f4a3b335fcb3547544c45ebce64bc 100644 --- a/src/blog/drafts/webdev/marko-intro.markodown +++ b/src/blog/drafts/webdev/marko-intro.markodown @@ -184,7 +184,7 @@ true but it's close enough for the example) All of the above is about how Marko's syntax works, and how it performs HTML generation with components. Marko also allows interactive components, but an -explaination of that is beyond the scope of this page, mostly since I have not +explanation of that is beyond the scope of this page, mostly since I have not used it. A brief example of it, modified from their documentation. ```marko diff --git a/src/blog/localization.ts b/src/blog/localization.ts index 17604daabdf34134d22bdaeab20cf2a0db3363d0..fdb5ba6490bd1f847e1b996ab4148a1c4ef68dbf 100644 --- a/src/blog/localization.ts +++ b/src/blog/localization.ts @@ -15,6 +15,7 @@ export const languages: LanguageMap = { translatedBy: ({ author }) => `translated by ${author}`, tableOfContents: "Contents", }, + // cspell:disable ko: { title: "한국어", return: "홈 페이지로 돌아가기", @@ -29,4 +30,5 @@ export const languages: LanguageMap = { translatedBy: ({ author }) => `traducido por ${author}`, tableOfContents: "Indice", }, + // cspell:enable }; diff --git a/src/blog/pages/webdev/one-year-next-app-router/en.mdo b/src/blog/pages/webdev/one-year-next-app-router/en.mdo index 1411f210d1be871de8f63b0de75999e0d105aaaa..808cff19fb7ea81b3b29b4ecf371c33d434998a6 100644 --- a/src/blog/pages/webdev/one-year-next-app-router/en.mdo +++ b/src/blog/pages/webdev/one-year-next-app-router/en.mdo @@ -37,7 +37,7 @@ database. Client components are the existing model, where there is code on the backend to generate HTML text and frontend code to manage the DOM using `window.document.*`. -> The first disaster: naming!! React is now using the wors +> The first disaster: naming!! React is now using the words > "server" and "client" to refer to > a very specific things, ignoring their existing definitions. This would be > fine, except Client components can run on the backend @@ -570,7 +570,7 @@ deployment. TanStack slowly took over the codebase, and we eventually deleted all of the Next.js stubs and gained all of the beautiful [type-safety features] that the TanStack Router provides. At the end, the site performed faster from every angle: Development Mode, Production page load times, Soft navigations, -and at a lower price than our Next depoyment with Vercel. +and at a lower price than our Next deployment with Vercel. [type-safety features]: https://tanstack.com/router/v1/docs/framework/react/guide/type-safety diff --git a/src/cspell.ts b/src/cspell.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6041e95c684d582e0f7b8a39b0f7ee9978e6046 --- /dev/null +++ b/src/cspell.ts @@ -0,0 +1,106 @@ +export interface SpellRange { + offset: number; + length: number; + suggestions: string[]; +} + +export type Settings = Record & { files: string[]; ignorePaths: string[] }; + +/** CSpell annotates every unknown word; `unknownWords` decides which of them count. */ +export async function spellCheck(uri: string, text: string, settings: ts.DeepReadonly, config: URL) { + const result = await cspellLib.spellCheckDocument( + { uri, text }, + { noConfigSearch: true, generateSuggestions: false }, + { url: config, settings: structuredClone(settings) as Settings, virtual: true }, + ); + for (const error of result.errors ?? []) throw error; + for (const error of result.configErrors ?? []) throw error.error; + for (const errors of result.dictionaryErrors?.values() ?? []) { + for (const error of errors) throw error; + } + if (!result.checked) throw new Error(`CSpell did not check ${uri}.`); + const { settingsUsed } = result; + const issues = result.issues.filter((issue) => { + if (issue.isFlagged || issue.issueType) return true; + switch (settingsUsed.unknownWords) { + case "report-common-typos": + return issue.hasPreferredSuggestions; + case "report-simple": + return issue.hasPreferredSuggestions || issue.hasSimpleSuggestions; + case "report-flagged": + return false; + default: + return true; + } + }); + return { issues, settingsUsed }; +} + +/** Replacements per unknown word, skipping anything the dictionaries forbid. */ +export async function suggestFixes({ issues, settingsUsed }: Awaited>) { + const dictionary = await cspellLib.getDictionary(settingsUsed); + const options = { + includeDefaultConfig: false, + includeTies: false, + strict: settingsUsed.caseSensitive ?? false, + numSuggestions: settingsUsed.numSuggestions, + numChanges: settingsUsed.suggestionNumChanges, + }; + const canSuggest = (word: string) => + !dictionary.isForbidden(word) && !dictionary.isNoSuggestWord(word, { ignoreCase: !options.strict }); + const byWord = new Map(); + for (const issue of issues) { + if (byWord.has(issue.text)) continue; + let candidates = (issue.suggestionsEx ?? []).filter((suggestion) => + suggestion.isPreferred + && canSuggest(suggestion.word) && canSuggest(suggestion.wordAdjustedToMatchCase ?? suggestion.word) + ); + if (!candidates.length && options.numSuggestions !== 0) { + // The word API applies dictionary edit weights that document suggestions omit. + const suggested = await cspellLib.suggestionsForWord(issue.text, options, settingsUsed); + candidates = suggested.suggestions.filter((suggestion) => + !suggestion.forbidden && !suggestion.noSuggest + && canSuggest(suggestion.wordAdjustedToMatchCase ?? suggestion.word) + ); + } + const preferred = candidates.filter((suggestion) => suggestion.isPreferred); + byWord.set( + issue.text, + [ + ...new Set( + (preferred.length ? preferred : candidates).map((suggestion) => + suggestion.wordAdjustedToMatchCase ?? suggestion.word + ), + ), + ].slice(0, options.numSuggestions), + ); + } + return byWord; +} + +let loaded: Promise<{ settings: Settings; config: URL }> | undefined; + +/** Live editor feedback, flagging exactly what the build-time check would. */ +export async function checkText(text: string): Promise { + loaded ??= (async () => { + const config = url.pathToFileURL(path.resolve("cspell.json")); + return { settings: await fs.readFile(config, "utf8").then(JSON.parse) as Settings, config }; + })(); + const { settings, config } = await loaded; + const result = await spellCheck("file:///question.md", text, settings, config); + // A `q:` line quotes the asker verbatim, so only the answer is clover's to spell. + const issues = result.issues.filter((issue) => !text.startsWith("q:", text.lastIndexOf("\n", issue.offset) + 1)); + if (!issues.length) return []; + const byWord = await suggestFixes({ ...result, issues }); + return issues.map((issue) => ({ + offset: issue.offset, + length: issue.text.length, + suggestions: byWord.get(issue.text) ?? [], + })); +} + +import type * as ts from "@clo/lib/ts"; +import * as cspellLib from "cspell-lib"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as url from "node:url"; diff --git a/src/databases.test.ts b/src/databases.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0dc5f970856951b8bc9534c370d2287771f7c7dc --- /dev/null +++ b/src/databases.test.ts @@ -0,0 +1,104 @@ +const dir = vi.hoisted(() => { + const fs = process.getBuiltinModule("node:fs"); + const os = process.getBuiltinModule("node:os"); + const dir = fs.mkdtempSync(os.tmpdir() + "/clover-databases-test-"); + Object.assign(process.env, { + CLOVER_DB: dir + "/db", + CLOVER_FILE_DERIVED: dir + "/derived", + CLOVER_SOT_KEY: "test", + }); + return dir; +}); +afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); + +for (const path of ["/2025/song.mp3", "/2025/.date", "/_unlisted/font.ttf", "/friends/2025/photo.jpg"]) { + const file = mediaFiles.createFile({ + path, + date: new Date(0), + hash: crypto.createHash("sha1").update(path).digest("hex"), + size: 0, + duration: 0, + dimensions: "", + contents: path === "/2025/song.mp3" ? "" : "PRIVATE " + path, + }); + const root = mediaFiles.db.get<{ id: number }>( + sql`insert into derived_roots (date, key) values (0, ${file.hash + "/opus"}) returning id`, + ).id; + mediaFiles.db.run(sql`insert into derived_files (file, root, size, hash) values ('a.webm', ${root}, 0, '')`); + mediaFiles.db.run(sql`insert into derived_refs (file, root) values (${file.id}, ${root})`); +} +permissions.set("/friends", 1); +questions.create("normal", "q: public"); +questions.create("reject", "q: PRIVATE rejected"); +questions.create("pending", JSON.stringify({ prompt: "PRIVATE pending", sourceName: "fox" })); +// a table no copy lists, like the search index questions once had +getDatabase("questions.sqlite").run(sql`create virtual table leftover using fts5(text)`); +getDatabase("questions.sqlite").run(sql`insert into leftover values ('PRIVATE rejected')`); + +async function download(name: string, headers?: Record) { + const response = await app.fetch(new Request(`http://localhost/db/${name}`, { headers })); + assert.equal(response.status, 200); + const bytes = Buffer.from(await response.arrayBuffer()); + const file = path.join(dir, `${name}.${crypto.randomUUID()}`); + fs.writeFileSync(file, bytes); + const db = new DatabaseSync(file, { readOnly: true }); + const column = (query: string) => db.prepare(query).all().map((row) => Object.values(row)[0]); + return { bytes, column }; +} + +test("a public copy has only what listings show anyone, and nothing else in its bytes", async () => { + const cache = await download("cache.sqlite"); + assert.ok(!cache.bytes.includes("PRIVATE") && !cache.bytes.includes("friends")); + assert.deepEqual(cache.column("select path from media_files order by path"), ["/", "/2025", "/2025/song.mp3"]); + assert.deepEqual(cache.column("select count(*) from permissions"), [0]); + assert.deepEqual(cache.column("select count(*) from derived_roots"), [1]); + assert.deepEqual(cache.column("select count(*) from derived_files"), [1]); + + const qa = await download("questions.sqlite"); + assert.ok(!qa.bytes.includes("PRIVATE")); + assert.deepEqual(qa.column("select text from questions"), ["q: public"]); + assert.deepEqual(qa.column("select name from sqlite_master where type = 'table' order by name"), [ + "clover_migrations", + "questions", + ]); +}); + +test("the key gets a whole copy, and a wrong key gets nothing", async () => { + const qa = await download("questions.sqlite", { Authorization: "test" }); + assert.deepEqual(qa.column("select count(*) from questions"), [3]); + const wrong = await app.fetch( + new Request("http://localhost/db/questions.sqlite", { headers: { Authorization: "?" } }), + ); + assert.equal(wrong.status, 401); + assert.equal((await app.fetch(new Request("http://localhost/db/constructor"))).status, 404); +}); + +test("a copy is written again once its database changes", async () => { + questions.create("normal", "q: newer", new Date(Date.UTC(2030, 0))); + const qa = await download("questions.sqlite"); + assert.deepEqual(qa.column("select text from questions order by qmid"), ["q: public", "q: newer"]); +}); + +test("a restored copy replaces every row, for connections that have the database open", async () => { + const db = getDatabase("restored.sqlite"); + db.run(sql`create table questions (text text)`); + const response = await app.fetch(new Request("http://localhost/db/questions.sqlite")); + await db.restore(UNWRAP(response.body)); + const rows = db.all<{ text: string }>(sql`select text from questions order by qmid`); + assert.deepEqual(rows.map((row) => row.text), ["q: public", "q: newer"]); +}); + +import { getDatabase } from "#sitegen/drizzle"; +import { UNWRAP } from "@clo/lib/assert"; +import { sql } from "drizzle-orm"; +import { strict as assert } from "node:assert"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterAll, test, vi } from "vitest"; +import { app } from "./databases.ts"; +import "./file-viewer/models/derived.ts"; +import * as mediaFiles from "./file-viewer/models/media-files.ts"; +import * as permissions from "./file-viewer/models/permissions.ts"; +import * as questions from "./q+a/models/questions.ts"; diff --git a/src/databases.ts b/src/databases.ts new file mode 100644 index 0000000000000000000000000000000000000000..12d966c762233db191166850db8325634238375b --- /dev/null +++ b/src/databases.ts @@ -0,0 +1,93 @@ +// Copies of the site's databases, which `node run download-db` saves into a +// clone of this repository so that it can build the site. With the source of +// truth's key, a copy is whole. Without it, a copy has only the tables listed +// below, less their private rows, which is what the site shows anyone. +export const app = new Hono(); + +const token = process.env.CLOVER_SOT_KEY ?? null; + +/** per database, the tables a public copy keeps, and the statements that first delete what is private */ +const publicCopies = new Map([ + ["cache.sqlite", { + tables: ["media_files", "permissions", "derived_roots", "derived_files", "derived_refs"], + redact: [ + // what listings leave out: paths behind a password, and hidden or unlisted names. + // a directory takes its children with it, and a file its derived refs + `delete from media_files where exists (select 1 from permissions where lower(media_files.path) glob lower(prefix) || '*') + or path like '%/.%' or path like '%/\\_unlisted%' escape '\\'`, + "delete from permissions", + "delete from derived_roots where id not in (select root from derived_refs)", + ], + }], + ["questions.sqlite", { + tables: ["questions"], + redact: [`delete from questions where type <> ${questions.types.normal}`], + }], +]); + +/** a connection of each database's own. copies read through it, and its `data_version` changes once another connection commits */ +const readers = new Map(); +incremental.onDispose(import.meta, () => readers.forEach((reader) => reader.close())); +/** the `data_version` each copy was written at, by its file */ +const versions = new Map(); + +app.get("/db/:name", (c) => { + const name = c.req.param("name"); + const publicCopy = publicCopies.get(name); + if (!publicCopy) return c.notFound(); + const authorization = c.req.header("Authorization"); + // a wrong key fails, so a deploy never builds from a public copy + if (authorization !== undefined && authorization !== token) { + return c.json({ error: "invalid authorization header" }, 401); + } + const file = getCopy(name, authorization === undefined ? publicCopy : null); + // a newer copy can replace the file, while this response reads the one it opened + const fd = fs.openSync(file, "r"); + c.header("Content-Type", "application/vnd.sqlite3"); + c.header("Content-Length", String(fs.fstatSync(fd).size)); + return c.body(stream.Readable.toWeb(fs.createReadStream(file, { fd })) as ReadableStream); +}); + +/** a file with a copy of a database, written again once the database changes */ +function getCopy(name: string, publicCopy: { tables: string[]; redact: string[] } | null): string { + const { location } = getDatabase(name); + let reader = readers.get(location); + if (!reader) readers.set(location, reader = new sqlite.DatabaseSync(location, { readOnly: true })); + const version = Number(reader.prepare("pragma data_version;").get()?.data_version); + const file = `${location}.${publicCopy ? "public" : "full"}`; + if (versions.get(file) === version && fs.existsSync(file)) return file; + + const temp = `${file}.tmp`; + fs.rmSync(temp, { force: true }); + reader.prepare("vacuum into ?").run(temp); + if (publicCopy) { + using copy = new sqlite.DatabaseSync(temp); + copy.exec("pragma foreign_keys = on;"); + for (const statement of publicCopy.redact) copy.exec(statement); + // every other table goes. a virtual table goes first, taking the tables that hold its rows + const objects = copy.prepare(` + select type, name from sqlite_master + where type in ('table', 'view', 'trigger') and name not like 'sqlite_%' + order by sql like 'create virtual%' desc + `).all() as Array<{ type: string; name: string }>; + for (const { type, name } of objects) { + if (type === "table" && (name === "clover_migrations" || publicCopy.tables.includes(name))) continue; + copy.exec(`drop ${type} if exists "${name}";`); + } + // a model creates the table again, empty, once the copy no longer says it was migrated + copy.exec("delete from clover_migrations where key not in (select name from sqlite_master where type = 'table');"); + // without `secure_delete`, deleted rows linger in a file until a vacuum writes it anew + copy.exec("vacuum;"); + } + fs.renameSync(temp, file); + versions.set(file, version); + return file; +} + +import { getDatabase } from "#sitegen/drizzle"; +import * as questions from "#src/q+a/models/questions.ts"; +import * as incremental from "@clo/lib/incremental"; +import { Hono } from "hono"; +import * as fs from "node:fs"; +import * as sqlite from "node:sqlite"; +import * as stream from "node:stream"; diff --git a/src/file-viewer/backend.ts b/src/file-viewer/backend.ts index 4234cac32bf6136a0e08dff3221b75248789ed71..38025758267c3e64bdcadc7214b8485897969d57 100644 --- a/src/file-viewer/backend.ts +++ b/src/file-viewer/backend.ts @@ -55,7 +55,7 @@ app.get("/file/*", async (c, next) => { if (!hasCotyledonCookie) { return serveAsset(c, "/file/cotyledon-speedbump", 403); } else if (rawFilePath === "/cotyledon") { - return serveAsset(c, "/file/cotyledon-enterance", 200); + return serveAsset(c, "/file/cotyledon-entrance", 200); } } while (rawFilePath.length > 1 && rawFilePath.endsWith("/")) { diff --git a/src/file-viewer/clofi.client.ts b/src/file-viewer/clofi.client.ts index 0b9351df8d3d0000f47319d9c67ba7158cf57089..78a0dfbd1085bbc0e6ebef45fa0dfea573ef6004 100644 --- a/src/file-viewer/clofi.client.ts +++ b/src/file-viewer/clofi.client.ts @@ -553,7 +553,7 @@ async function navigate(pathname: string, pushState: boolean) { history.pushState(null, "", `/file${filePath.length > 1 ? filePath : ""}`); } - const startScrollleft = filesContainer.scrollLeft; + const startScrollLeft = filesContainer.scrollLeft; if (currentSplit[0] !== filePathSplit[0]) { if (currentSplit[0] === "cotyledon") { @@ -625,11 +625,11 @@ async function navigate(pathname: string, pushState: boolean) { } updateWidths(); - filesContainer.scrollLeft = startScrollleft; + filesContainer.scrollLeft = startScrollLeft; requestAnimationFrame(() => { updateWidths(); - filesContainer.scrollLeft = startScrollleft; - snapToEnd(startScrollleft); + filesContainer.scrollLeft = startScrollLeft; + snapToEnd(startScrollLeft); }); } diff --git a/src/file-viewer/highlight.ts b/src/file-viewer/highlight.ts index 80d82b0c8649306f8457e2be63466b895d1e317a..63312f8c54ac78321edf176debf7183c4e201181 100644 --- a/src/file-viewer/highlight.ts +++ b/src/file-viewer/highlight.ts @@ -94,9 +94,9 @@ interface HighlightLinesOptions { language: Language; } -export function getStyle(scopesToCheck: string[], langugage: Language) { +export function getStyle(scopesToCheck: string[], language: Language) { for (const scope of scopes) { - if (scope[2] && scope[2] !== langugage) continue; + if (scope[2] && scope[2] !== language) continue; const find = scopesToCheck.find((s) => s.startsWith(scope[0])); if (find) { return scope[1]; @@ -197,26 +197,6 @@ export async function highlightCode(code: string, language: Language) { return html; } -// export async function main() { -// validate exts -// for (const ext of languages) { -// if ( -// !fs.existsSync( -// path.join(import.meta.dirname, `highlight-grammar/${ext}.plist`), -// ) -// ) { -// console.error(`Missing grammar for ${ext}`); -// } -// // Sanity check -// await highlightCode("wwwwwwwwwwwaaaaaaaaaaaaaaaa", ext); -// } -// } -// -// await highlightCode( -// `return snow!`, -// "tsx", -// ); -// import * as fs from "#sitegen/fs"; import * as async from "@clo/lib/async"; import * as string from "@clo/lib/string"; diff --git a/src/file-viewer/models/derived.ts b/src/file-viewer/models/derived.ts index bc28024dd21dff99d45ff4fe5499685967f773dd..dc8bb4cc23619206c62f0cea0685dc63997e2005 100644 --- a/src/file-viewer/models/derived.ts +++ b/src/file-viewer/models/derived.ts @@ -1,4 +1,4 @@ -// Uncompressed files are read directly from the media store root. Derivied +// Uncompressed files are read directly from the media store root. Derived // assets like compressed files, optimized images, and streamable video are // stored in the `derived` folder. After scanning, the derived assets are // uploaded into the store (storage1/clofi-derived dataset on NAS). Since diff --git a/src/file-viewer/pages/file.cotyledon-enterance.marko b/src/file-viewer/pages/file.cotyledon-entrance.marko similarity index 100% rename from src/file-viewer/pages/file.cotyledon-enterance.marko rename to src/file-viewer/pages/file.cotyledon-entrance.marko diff --git a/src/file-viewer/sync.ts b/src/file-viewer/sync.ts index b5e50e13c2f4ca2686e1b0b23714518ebcdd21fb..5fce0a5687f615a2a9ecc5b62c48bc1a3917545a 100644 --- a/src/file-viewer/sync.ts +++ b/src/file-viewer/sync.ts @@ -5,10 +5,8 @@ // resolve through the same path — there is no polling and no TTL. // // Pulls are ETag-guarded (no-change is a tiny 304; the etag is persisted so -// a reboot does not re-download an unchanged database), land in a temp -// file, and are copied into .clover/cache.sqlite with sqlite's backup. The -// file stays the same, so every open connection reads the new rows, and the -// incremental graph sees the change like any other write. +// a reboot does not re-download an unchanged database), and land through +// `db.restore`, so every open connection reads the new rows. const console = log.scoped("sync"); const token = process.env.CLOVER_SOT_KEY ?? null; @@ -24,14 +22,13 @@ if (!enabled) { let inFlight: Promise | null = null; -export function revalidate(): Promise { +function revalidate(): Promise { return inFlight ??= revalidateInner().finally(() => { inFlight = null; }); } async function revalidateInner(): Promise { - const { location } = mediaFiles.db; const res = await fetch(`${sotUrl}/db`, { headers: { Authorization: token!, @@ -43,20 +40,7 @@ async function revalidateInner(): Promise { if (!res.ok || !res.body) { throw new Error(`source of truth responded ${res.status} ${res.statusText}`); } - - // another pull can be landing at once, from this process's last copy of - // this code or from another process - const tmp = `${location}.${crypto.randomUUID()}.tmp`; - try { - await stream.promises.pipeline( - stream.Readable.fromWeb(res.body as any), - fs.createWriteStream(tmp), - ); - using snapshot = new sqlite.DatabaseSync(tmp, { readOnly: true }); - await sqlite.backup(snapshot, location); - } finally { - await fsp.rm(tmp, { force: true }); - } + await mediaFiles.db.restore(res.body); saveEtag(res.headers.get("ETag")); console.info(`database updated (${string.formatByteSize(Number(res.headers.get("Content-Length") ?? "0"))})`); } @@ -160,9 +144,6 @@ if (enabled) { } import * as fs from "node:fs"; -import * as fsp from "node:fs/promises"; -import * as sqlite from "node:sqlite"; -import * as stream from "node:stream"; import * as mediaFiles from "#src/file-viewer/models/media-files.ts"; diff --git a/src/file-viewer/tags/cotyledon-ending.marko b/src/file-viewer/tags/cotyledon-ending.marko index 38f744baed38e6558ac00f3aab927a80f1ae2937..c7aa34ca2eff4c2f89300d6c8873e073a1f7881c 100644 --- a/src/file-viewer/tags/cotyledon-ending.marko +++ b/src/file-viewer/tags/cotyledon-ending.marko @@ -3,7 +3,7 @@ // This file contains spoilers for COTYLEDON // Consider reading through the entire archive before picking // apart this code, as this contains the ending sections, which -// contains very percise storytelling. You've been warned... +// contains very precise storytelling. You've been warned... // // --> https://paperclover.net/file/cotyledon <-- // diff --git a/src/file-viewer/tags/cotyledon-speedbump.marko b/src/file-viewer/tags/cotyledon-speedbump.marko index 56e561cfde1a8dc2ec1665a2613f3d2bf3afd9a3..feda1a304cf597b3a2de235f821197f2836540cb 100644 --- a/src/file-viewer/tags/cotyledon-speedbump.marko +++ b/src/file-viewer/tags/cotyledon-speedbump.marko @@ -40,7 +40,7 @@

    by entering our museum, you agree not to use your camera. flash off isn't - enough; the bits and bytes are alergic even to a camera's sensor + enough; the bits and bytes are allergic even to a camera's sensor

    (in english: please do not store downloads after you're done viewing them) diff --git a/src/file-viewer/tags/media-panel.marko b/src/file-viewer/tags/media-panel.marko index bc3f693780ad5f5886d2abc29f62571f03915fc5..9d312f43c1f58c3f17c8cca0904cc0fe86fa3965 100644 --- a/src/file-viewer/tags/media-panel.marko +++ b/src/file-viewer/tags/media-panel.marko @@ -13,7 +13,7 @@ client import "../clofi.client.ts"; // ensure video stuff is loaded for CSRs client import "../../tags/clover-video.client.ts"; -static const cotyledonEndingFile = "/2024/for everone"; +static const cotyledonEndingFile = "/2024/for everyone"; diff --git a/src/file-viewer/views/lofideon.marko b/src/file-viewer/views/lofideon.marko index dbaac8981d73acb064fe24e257730345e8fae3c5..b4c804c13eec3e88d792544c5c481a81fe9f47f8 100644 --- a/src/file-viewer/views/lofideon.marko +++ b/src/file-viewer/views/lofideon.marko @@ -16,7 +16,7 @@ export const theme = {

    by entering our museum, you agree not to use your camera. flash off isn't - enough; the bits and bytes are alergic even to a camera's sensor + enough; the bits and bytes are allergic even to a camera's sensor

    diff --git a/src/pages/history-of-japan-reanimated.marko b/src/pages/history-of-japan-reanimated.marko index 283ca9b0fbf56e3c02b8519c198884325d05364c..265225cb2080f416a22eeb6fddc00ddc472daa90 100644 --- a/src/pages/history-of-japan-reanimated.marko +++ b/src/pages/history-of-japan-reanimated.marko @@ -154,7 +154,7 @@ export const theme = {

    what's next

    - from the success of this project, a much more ambitous project is being + from the success of this project, a much more ambitious project is being planned: the reanimation for 'history of the entire world, i guess'. i am joining the project as one of the co-hosts. we are currently doing initial planning, all artists (music, visual, narration) interested should fill out diff --git a/src/pages/license.mdo b/src/pages/license.mdo index aefc51890321cde1ab0027d9d61a6a05956dc64d..28a9a88faf492c279d2580c2a863548d0406dc86 100644 --- a/src/pages/license.mdo +++ b/src/pages/license.mdo @@ -22,7 +22,7 @@ refers to downloadable files available within the [paperclover.net/file](/file) permission is given to download, view, or otherwise store a copy of any media file. however, media files must not be uploaded, published, or otherwise -distributed over the internet or other electronical communication devices. +distributed over the internet or other electronic communication devices. all uses of media files must be used for non-commercial purposes. paperclover.net provides media files as-is, without any warranty or liability. diff --git a/src/pages/log.mdo b/src/pages/log.mdo index 52402fc3df611c0d4671f162647988ad7e7999a1..56da4a675d29daba987c54caa6359be497fcc93f 100644 --- a/src/pages/log.mdo +++ b/src/pages/log.mdo @@ -37,7 +37,7 @@ tags: [react markdown] i published a new library named `@clo/react-markdown`. originally, i wanted to have my own version of the `streamdown` package from vercel. but i didn't realize how much of a loser company they all are. i wasn't even trying and i -made a library like a hundred times better and simpler than theres. because of +made a library like a hundred times better and simpler than theirs. because of the awesome success here, instead of calling mine "memo markdown", i just said "yea, this covers every markdown use case for react" and called it React Markdown. @@ -309,7 +309,7 @@ lighter alternatives: - `puppeteer` with `playwright` - `codemirror` with `