@coywolf/emdash 0.35.13 → 0.35.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +2 -0
  2. package/docs/README.md +45 -0
  3. package/docs/ai.md +39 -0
  4. package/docs/backups.md +88 -0
  5. package/docs/breadcrumbs.md +59 -0
  6. package/docs/code-blocks.md +21 -0
  7. package/docs/content-urls.md +41 -0
  8. package/docs/custom-blocks.md +27 -0
  9. package/docs/discovery.md +32 -0
  10. package/docs/files.md +44 -0
  11. package/docs/form-uploads.md +59 -0
  12. package/docs/headings.md +37 -0
  13. package/docs/images.md +141 -0
  14. package/docs/internals.md +13 -0
  15. package/docs/links.md +34 -0
  16. package/docs/performance.md +72 -0
  17. package/docs/redirects.md +84 -0
  18. package/docs/releasing.md +21 -0
  19. package/docs/reviews.md +89 -0
  20. package/docs/robots.md +81 -0
  21. package/docs/schema.md +79 -0
  22. package/docs/search.md +114 -0
  23. package/docs/videos.md +48 -0
  24. package/docs/wordpress-import.md +198 -0
  25. package/package.json +11 -2
  26. package/src/version.ts +1 -1
  27. package/src/ai/logic.test.ts +0 -187
  28. package/src/ai/queue.test.ts +0 -97
  29. package/src/ai/wikidata.test.ts +0 -184
  30. package/src/backups/dump.test.mjs +0 -287
  31. package/src/backups/restore.test.mjs +0 -103
  32. package/src/codeBlocks/render.test.ts +0 -152
  33. package/src/files/counts.test.ts +0 -114
  34. package/src/files/format.test.ts +0 -93
  35. package/src/files/r2-rules.test.ts +0 -30
  36. package/src/files/sigv4.test.ts +0 -64
  37. package/src/headings/headings.test.ts +0 -159
  38. package/src/pageCache/lib.test.mjs +0 -224
  39. package/src/pageCache/warm.test.mjs +0 -670
  40. package/src/redirects/slug-core.test.ts +0 -50
  41. package/src/robots/rep.test.ts +0 -335
  42. package/src/robots/rules.test.ts +0 -251
  43. package/src/schema/graph.test.mjs +0 -522
@@ -1,287 +0,0 @@
1
- // Unit tests for the D1 dump: rows bigger than D1's statement limit restore intact.
2
- // Run: node --test src/backups/dump.test.mjs (Node 22.6+ strips the types from dump.ts)
3
- import assert from "node:assert/strict";
4
- import { DatabaseSync } from "node:sqlite";
5
- import { test } from "node:test";
6
-
7
- const { dumpDatabase, splitText, MAX_STATEMENT_BYTES, nextPageSize, PAGE_TARGET_BYTES } = await import("./dump.ts");
8
-
9
- /** The slice of the D1 binding that dumpDatabase uses, over node:sqlite. */
10
- function d1(db) {
11
- return {
12
- prepare(sql) {
13
- let params = [];
14
- const stmt = {
15
- bind(...args) {
16
- params = args;
17
- return stmt;
18
- },
19
- async all() {
20
- return { results: db.prepare(sql).all(...params) };
21
- },
22
- };
23
- return stmt;
24
- },
25
- };
26
- }
27
-
28
- function seed() {
29
- const db = new DatabaseSync(":memory:");
30
- db.exec(`CREATE TABLE options (name TEXT PRIMARY KEY, value TEXT, revision INTEGER);
31
- CREATE TABLE ec_posts (id TEXT PRIMARY KEY, slug TEXT, content TEXT, excerpt TEXT);
32
- CREATE TABLE log (line TEXT);`);
33
- const insert = (sql, ...v) => db.prepare(sql).run(...v);
34
- // 250 KB with quotes, CR/LF, emoji (surrogate pairs) and accents, so pieces split on awkward characters.
35
- const big = Array.from({ length: 5000 }, (_, i) => `Line ${i}: it's "quoted" — café 🦊\r\n`).join("");
36
- insert("INSERT INTO ec_posts VALUES (?, ?, ?, ?)", "p1", "long", big, big.slice(0, 120_000));
37
- insert("INSERT INTO ec_posts VALUES (?, ?, ?, ?)", "p2", "short", "hello\nworld", null);
38
- insert("INSERT INTO options VALUES (?, ?, ?)", "site:title", "Coywolf", 1);
39
- insert("INSERT INTO options VALUES (?, ?, ?)", "plugin:coywolf-pack:cache:discovery:llms", big, 1);
40
- insert("INSERT INTO log VALUES (?)", big);
41
- return { db, big };
42
- }
43
-
44
- test("oversized rows are split into statements under the limit and restore intact", async () => {
45
- const { db, big } = seed();
46
- const { sql } = await dumpDatabase(d1(db));
47
- const statements = sql.trim().split("\n");
48
- for (const s of statements) assert.ok(new TextEncoder().encode(s).length <= MAX_STATEMENT_BYTES, `statement of ${s.length} chars`);
49
- assert.ok(statements.some((s) => s.startsWith('UPDATE "ec_posts"')), "long post written in pieces");
50
-
51
- const restored = new DatabaseSync(":memory:");
52
- for (const s of statements) restored.exec(s);
53
- const post = restored.prepare("SELECT * FROM ec_posts WHERE id = 'p1'").get();
54
- assert.equal(post.content, big);
55
- assert.equal(post.excerpt, big.slice(0, 120_000));
56
- assert.equal(restored.prepare("SELECT content FROM ec_posts WHERE id = 'p2'").get().content, "hello\nworld");
57
- assert.equal(restored.prepare("SELECT line FROM log").get().line, big, "table without a primary key uses rowid");
58
- });
59
-
60
- test("plugin caches are left out; other options are kept", async () => {
61
- const { db } = seed();
62
- const { sql, rows } = await dumpDatabase(d1(db));
63
- assert.ok(!sql.includes("cache:discovery:llms"));
64
- assert.ok(sql.includes("site:title"));
65
- assert.equal(rows, 4);
66
- });
67
-
68
- test("splitText never cuts a surrogate pair and rejoins exactly", () => {
69
- const text = "🦊".repeat(40_000);
70
- const pieces = splitText(text);
71
- assert.ok(pieces.length > 1);
72
- assert.equal(pieces.join(""), text);
73
- for (const p of pieces) assert.ok(!/^[\udc00-\udfff]/.test(p));
74
- });
75
-
76
- test("rows are paged by rowid across pages, in rowid order, and restore intact", async () => {
77
- const db = new DatabaseSync(":memory:");
78
- db.exec(`CREATE TABLE items (id TEXT PRIMARY KEY, n INTEGER);
79
- CREATE TABLE plain (line TEXT);
80
- CREATE TABLE odd (oid TEXT, v INTEGER);
81
- CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT) WITHOUT ROWID;`);
82
- for (let i = 0; i < 1234; i++) {
83
- db.prepare("INSERT INTO items VALUES (?, ?)").run(`id-${String(1234 - i).padStart(4, "0")}`, i);
84
- db.prepare("INSERT INTO plain VALUES (?)").run(`line ${i}`);
85
- db.prepare("INSERT INTO odd VALUES (?, ?)").run(`o${i}`, i);
86
- db.prepare("INSERT INTO kv VALUES (?, ?)").run(`k${i}`, `v${i}`);
87
- }
88
- // Gaps in the rowids, as deletes leave them.
89
- db.exec("DELETE FROM items WHERE n % 7 = 0; DELETE FROM plain WHERE rowid BETWEEN 400 AND 650;");
90
- const queries = [];
91
- const binding = d1(db);
92
- const spy = { prepare: (sql) => (queries.push(sql), binding.prepare(sql)) };
93
- const { sql, rows } = await dumpDatabase(spy);
94
- assert.ok(queries.some((q) => /FROM "items" WHERE rowid > \? ORDER BY rowid LIMIT \?/.test(q)), "paged by rowid");
95
- assert.ok(queries.some((q) => /FROM "odd" ORDER BY rowid LIMIT \? OFFSET \?/.test(q)), "a column named oid falls back to OFFSET");
96
- assert.ok(queries.some((q) => /FROM "kv" LIMIT \? OFFSET \?/.test(q)), "WITHOUT ROWID uses OFFSET");
97
- assert.ok(!sql.includes("__coywolf_rowid"), "the paging cursor is never written");
98
-
99
- const restored = new DatabaseSync(":memory:");
100
- for (const s of sql.trim().split("\n")) restored.exec(s);
101
- for (const table of ["items", "plain", "odd", "kv"]) {
102
- assert.deepEqual(restored.prepare(`SELECT * FROM ${table} ORDER BY 1`).all(), db.prepare(`SELECT * FROM ${table} ORDER BY 1`).all(), table);
103
- }
104
- // Same order as before (rowid order): the n column of items rises.
105
- const ns = [...sql.matchAll(/INSERT INTO "items" \("id","n"\) VALUES\('[^']+',(\d+)\);/g)].map((m) => Number(m[1]));
106
- assert.deepEqual(ns, [...ns].sort((a, b) => a - b));
107
- assert.equal(rows, db.prepare("SELECT (SELECT count(*) FROM items) + (SELECT count(*) FROM plain) + 1234 + 1234 AS n").get().n);
108
- });
109
-
110
- /** An R2 bucket that keeps what's put and records multipart uploads. */
111
- function fakeBucket() {
112
- const store = new Map();
113
- const uploads = [];
114
- return {
115
- store,
116
- uploads,
117
- async put(key, body) {
118
- store.set(key, Buffer.from(body));
119
- },
120
- async createMultipartUpload(key) {
121
- const parts = [];
122
- const upload = { key, parts, completed: false, aborted: false };
123
- uploads.push(upload);
124
- return {
125
- async uploadPart(n, body) {
126
- parts.push({ n, size: body.byteLength, body: Buffer.from(body) });
127
- return { partNumber: n, etag: `e${n}` };
128
- },
129
- async complete(list) {
130
- upload.completed = true;
131
- store.set(key, Buffer.concat(list.map((p) => parts.find((x) => x.n === p.partNumber).body)));
132
- },
133
- async abort() {
134
- upload.aborted = true;
135
- },
136
- };
137
- },
138
- };
139
- }
140
-
141
- test("the dump streams into the bucket: multipart parts of one size, same bytes as the whole", async () => {
142
- const { writeDumpStream } = await import("./store.ts");
143
- const { dumpLines } = await import("./dump.ts");
144
- const { gunzipSync } = await import("node:zlib");
145
- const { createHash } = await import("node:crypto");
146
- const { db } = seed();
147
- const lines = [];
148
- for await (const line of dumpLines(d1(db))) lines.push(line);
149
- async function* replay() {
150
- yield* lines;
151
- }
152
-
153
- const big = fakeBucket();
154
- const result = await writeDumpStream(big, "2026-10-05T1200Z", "site.sql.gz", replay(), 4096);
155
- const stored = big.store.get("d1/2026-10-05T1200Z/site.sql.gz");
156
- assert.equal(big.uploads.length, 1);
157
- const sizes = big.uploads[0].parts.map((p) => p.size);
158
- assert.ok(sizes.length > 2, `${sizes.length} parts`);
159
- assert.ok(sizes.slice(0, -1).every((s) => s === 4096), "every part but the last is exactly one part size");
160
- assert.equal(result.bytes, stored.length);
161
- assert.equal(result.sha256, createHash("sha256").update(stored).digest("hex"));
162
- assert.equal(gunzipSync(stored).toString("utf8"), `${lines.join("\n")}\n`);
163
-
164
- const small = fakeBucket();
165
- await writeDumpStream(small, "2026-10-05T1200Z", "site.sql.gz", replay());
166
- assert.equal(small.uploads.length, 0, "under one part: a single put");
167
- assert.equal(gunzipSync(small.store.get("d1/2026-10-05T1200Z/site.sql.gz")).toString("utf8"), (await dumpDatabase(d1(db))).sql);
168
- });
169
-
170
- test("a failed dump aborts the upload and reports the dump's error", async () => {
171
- const { writeDumpStream } = await import("./store.ts");
172
- const bucket = fakeBucket();
173
- async function* failing() {
174
- for (let i = 0; i < 4000; i++) yield `${Math.random().toString(36)}${Math.random().toString(36)}`;
175
- throw new Error("D1 went away");
176
- }
177
- await assert.rejects(() => writeDumpStream(bucket, "2026-10-05T1200Z", "x.sql.gz", failing(), 1024), /D1 went away/);
178
- assert.equal(bucket.uploads.length, 1);
179
- assert.equal(bucket.uploads[0].aborted, true);
180
- assert.equal(bucket.store.size, 0);
181
- });
182
-
183
- test("a failed upload stops the dump instead of hanging", async () => {
184
- const { writeDumpStream } = await import("./store.ts");
185
- let aborted = false;
186
- const bucket = {
187
- async put() {},
188
- async createMultipartUpload() {
189
- return {
190
- uploadPart: async () => {
191
- throw new Error("R2 unavailable");
192
- },
193
- complete: async () => {},
194
- abort: async () => {
195
- aborted = true;
196
- },
197
- };
198
- },
199
- };
200
- let produced = 0;
201
- async function* endless() {
202
- for (;;) {
203
- produced++;
204
- yield `${Math.random().toString(36)}${Math.random().toString(36)}${Math.random().toString(36)}`;
205
- }
206
- }
207
- await assert.rejects(() => writeDumpStream(bucket, "2026-10-05T1200Z", "x.sql.gz", endless(), 1024), /R2 unavailable/);
208
- assert.equal(aborted, true);
209
- const stopped = produced;
210
- await new Promise((resolve) => setTimeout(resolve, 20));
211
- assert.equal(produced, stopped, "the dump stops reading rows");
212
- });
213
-
214
- test("reads are sized by bytes: big rows mean fewer rows per read, never more than 500", () => {
215
- assert.equal(nextPageSize(0, 0), 500);
216
- assert.equal(nextPageSize(500 * 200, 500), 500, "small rows: the full page");
217
- assert.equal(nextPageSize(10 * 1024 * 1024, 10), Math.floor(PAGE_TARGET_BYTES / (1024 * 1024)), "1 MB rows: about 8 a read");
218
- assert.equal(nextPageSize(50 * 1024 * 1024, 1), 1, "never below one row");
219
- });
220
-
221
- test("a read D1 refuses as too large is asked again for fewer rows; offset paging skips nothing", async () => {
222
- // 600 rows of ~40 KB each in a table paged by OFFSET (a column named rowid shadows the real one),
223
- // with a fake D1 that refuses any result over 5 MB, as D1 does over 50 MB.
224
- const db = new DatabaseSync(":memory:");
225
- db.exec("CREATE TABLE odd (rowid TEXT, body TEXT)");
226
- const body = "x".repeat(40_000);
227
- for (let i = 0; i < 600; i++) db.prepare("INSERT INTO odd VALUES (?, ?)").run(`r${i}`, `${i}:${body}`);
228
- const base = d1(db);
229
- let refused = 0;
230
- const limited = {
231
- prepare(sql) {
232
- const stmt = base.prepare(sql);
233
- let params = [];
234
- return {
235
- bind(...args) {
236
- params = args;
237
- stmt.bind(...args);
238
- return this;
239
- },
240
- async all() {
241
- const out = await stmt.all();
242
- const size = JSON.stringify(out.results).length;
243
- if (/FROM "odd"/.test(sql) && size > 5_000_000) {
244
- refused++;
245
- throw new Error("D1_ERROR: result too large");
246
- }
247
- return out;
248
- },
249
- };
250
- },
251
- };
252
- const { sql, rows } = await dumpDatabase(limited);
253
- assert.ok(refused >= 1, "the first 500-row read was refused");
254
- assert.equal(rows, 600);
255
- const restored = new DatabaseSync(":memory:");
256
- for (const s of sql.trim().split("\n")) restored.exec(s);
257
- assert.equal(restored.prepare("SELECT count(*) AS n FROM odd").get().n, 600, "every row once");
258
- assert.equal(restored.prepare("SELECT count(DISTINCT rowid) AS n FROM odd").get().n, 600, "no row twice");
259
- });
260
-
261
- test("other read errors aren't retried with fewer rows: they fail the backup at once", async () => {
262
- const db = new DatabaseSync(":memory:");
263
- db.exec("CREATE TABLE t (id TEXT PRIMARY KEY, v TEXT)");
264
- db.prepare("INSERT INTO t VALUES ('a', 'b')").run();
265
- const base = d1(db);
266
- let reads = 0;
267
- const failing = {
268
- prepare(sql) {
269
- const stmt = base.prepare(sql);
270
- return {
271
- bind(...args) {
272
- stmt.bind(...args);
273
- return this;
274
- },
275
- async all() {
276
- if (/FROM "t"/.test(sql)) {
277
- reads++;
278
- throw new Error("D1_ERROR: Network connection lost.");
279
- }
280
- return stmt.all();
281
- },
282
- };
283
- },
284
- };
285
- await assert.rejects(dumpDatabase(failing), /Network connection lost/);
286
- assert.equal(reads, 1, "asked once, not again with fewer rows");
287
- });
@@ -1,103 +0,0 @@
1
- // Unit tests for restoring missing media in batches.
2
- // Run: node --test src/backups/restore.test.mjs (Node 22.6+ strips the types from store.ts)
3
- import assert from "node:assert/strict";
4
- import { test } from "node:test";
5
-
6
- const { restoreMissingMedia, mirrorBucket, pruneBackups } = await import("./store.ts");
7
-
8
- /** The slice of an R2 bucket that restoreMissingMedia uses, paging 100 keys at a time like R2's default 1,000. */
9
- function bucket(entries = {}) {
10
- const store = new Map(Object.entries(entries));
11
- return {
12
- store,
13
- async list({ prefix = "", cursor, delimiter } = {}) {
14
- const keys = [...store.keys()].filter((k) => k.startsWith(prefix)).sort();
15
- if (delimiter) {
16
- const dirs = [...new Set(keys.map((k) => k.slice(prefix.length)).filter((k) => k.includes(delimiter)).map((k) => prefix + k.split(delimiter)[0] + delimiter))];
17
- return { objects: [], delimitedPrefixes: dirs, truncated: false };
18
- }
19
- const start = cursor ? Number(cursor) : 0;
20
- const page = keys.slice(start, start + 100);
21
- const truncated = start + 100 < keys.length;
22
- return { objects: page.map((key) => ({ key, etag: `e-${store.get(key)}` })), truncated, cursor: truncated ? String(start + 100) : undefined };
23
- },
24
- async get(key) {
25
- return store.has(key) ? { body: store.get(key), httpMetadata: {} } : null;
26
- },
27
- async put(key, body) {
28
- store.set(key, body);
29
- },
30
- async delete(keys) {
31
- for (const k of [].concat(keys)) store.delete(k);
32
- },
33
- };
34
- }
35
-
36
- test("restores missing media in batches until nothing is pending, never overwriting", async () => {
37
- const mirror = {};
38
- for (let i = 0; i < 450; i++) mirror[`media/current/file-${String(i).padStart(3, "0")}.jpg`] = `backup-${i}`;
39
- const backups = bucket(mirror);
40
- const media = bucket({ "file-000.jpg": "live-0" });
41
-
42
- const runs = [];
43
- for (;;) {
44
- const result = await restoreMissingMedia(media, backups, 200);
45
- runs.push(result);
46
- if (!result.pending) break;
47
- }
48
- assert.deepEqual(runs.map((r) => [r.restored, r.pending]), [[200, 249], [200, 49], [49, 0]]);
49
- assert.equal(media.store.size, 450);
50
- assert.equal(media.store.get("file-000.jpg"), "live-0", "existing files are left alone");
51
- assert.equal(media.store.get("file-449.jpg"), "backup-449");
52
- assert.deepEqual(await restoreMissingMedia(media, backups, 200), { restored: 0, checked: 450, pending: 0 });
53
- });
54
-
55
- test("private form uploads mirror to uploads/ and restore from there, separate from media", async () => {
56
- const uploads = bucket({ "forms/a.pdf": "A", "forms/b.png": "B" });
57
- const backups = bucket({ "media/current/photo.jpg": "P" });
58
- const first = await mirrorBucket(uploads, backups, "2020-01-01T0600Z", "uploads");
59
- assert.deepEqual(first, { copied: 2, preserved: 0, total: 2, pending: 0 });
60
- assert.equal(backups.store.get("uploads/current/forms/a.pdf"), "A");
61
-
62
- // A replaced upload keeps its old copy under uploads/changed/<stamp>/; a deleted one too.
63
- uploads.store.set("forms/a.pdf", "A2");
64
- uploads.store.delete("forms/b.png");
65
- await mirrorBucket(uploads, backups, "2020-01-02T0600Z", "uploads");
66
- assert.equal(backups.store.get("uploads/current/forms/a.pdf"), "A2");
67
- assert.equal(backups.store.get("uploads/changed/2020-01-02T0600Z/forms/a.pdf"), "A");
68
- assert.equal(backups.store.get("uploads/changed/2020-01-02T0600Z/forms/b.png"), "B");
69
- assert.ok(!backups.store.has("uploads/current/forms/b.png"));
70
- assert.equal(backups.store.get("media/current/photo.jpg"), "P", "media mirror untouched");
71
-
72
- const empty = bucket();
73
- const restored = await restoreMissingMedia(empty, backups, 200, "uploads");
74
- assert.deepEqual(restored, { restored: 1, checked: 1, pending: 0 });
75
- assert.equal(empty.store.get("forms/a.pdf"), "A2");
76
- assert.ok(!empty.store.has("photo.jpg"), "media isn't restored into the uploads bucket");
77
-
78
- // Old replaced copies are pruned like media's.
79
- assert.equal(await pruneBackups(backups, 1), 0);
80
- assert.ok(![...backups.store.keys()].some((k) => k.startsWith("uploads/changed/2020-01-02")), "uploads/changed pruned");
81
- });
82
-
83
- test("the mirror copies a few files at a time, never more than its limit", async () => {
84
- const { MIRROR_CONCURRENCY } = await import("./store.ts");
85
- const files = {};
86
- for (let i = 0; i < 40; i++) files[`f${i}.jpg`] = `v${i}`;
87
- const media = bucket(files);
88
- const backups = bucket({ "media/current/f0.jpg": "old", "media/current/gone.jpg": "G" });
89
- let inFlight = 0;
90
- let most = 0;
91
- const slowGet = media.get.bind(media);
92
- media.get = async (key) => {
93
- most = Math.max(most, ++inFlight);
94
- await new Promise((resolve) => setTimeout(resolve, 2));
95
- inFlight--;
96
- return slowGet(key);
97
- };
98
- const result = await mirrorBucket(media, backups, "2020-01-03T0600Z", "media", 30);
99
- assert.equal(most, MIRROR_CONCURRENCY);
100
- assert.deepEqual(result, { copied: 30, preserved: 1, total: 40, pending: 11 });
101
- assert.equal(backups.store.get("media/changed/2020-01-03T0600Z/f0.jpg"), "old", "a replaced file's old copy is kept before it's overwritten");
102
- assert.equal(backups.store.get("media/current/f0.jpg"), "v0");
103
- });
@@ -1,152 +0,0 @@
1
- /**
2
- * Run: node --test src/codeBlocks/render.test.ts (Node 22.18+ strips types)
3
- */
4
- import assert from "node:assert/strict";
5
- import { test } from "node:test";
6
-
7
- // A literal ".ts" specifier would trip tsc (no allowImportingTsExtensions); Node needs the real file name.
8
- const path = "./render" + ".ts";
9
- const r: typeof import("./render.js") = await import(path);
10
-
11
- test("escapes text in highlighted and plain output", () => {
12
- const html = r.renderBlock({ code: `<script>alert("x")</script> & </code>`, language: "zig" }, { label: true, copy: false, lineNumbers: false });
13
- assert.ok(!html.includes("<script>"));
14
- assert.ok(html.includes("&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt; &amp; &lt;/code&gt;"));
15
- const hl = r.highlightLines(`const a = "<b>";`, "javascript").join("\n");
16
- assert.ok(hl.includes("&lt;b&gt;"));
17
- assert.ok(!hl.includes("<b>"));
18
- });
19
-
20
- test("shows the language, not the file name, and escapes an unknown language label", () => {
21
- const html = r.renderBlock({ code: "x", language: `"><img src=x>`, filename: `<i>a.ts</i>` }, { label: true, copy: false, lineNumbers: false });
22
- assert.ok(!html.includes("<img"));
23
- assert.ok(!html.includes("a.ts"));
24
- assert.match(html, /class="language-img-src-x"/);
25
- const ts = r.renderBlock({ code: "let a = 1;", language: "typescript", filename: "greet.ts" }, { label: true, copy: true, lineNumbers: false });
26
- assert.match(ts, /<div class="cw-code-head"><span class="cw-code-label">TypeScript<\/span><\/div>/);
27
- assert.ok(!ts.includes("greet.ts"));
28
- });
29
-
30
- test("only hljs classes survive hast → HTML", () => {
31
- const lines = r.hastToLines({
32
- type: "root",
33
- children: [
34
- {
35
- type: "element",
36
- tagName: "span",
37
- properties: { className: ["hljs-title", "function_", "evil\" onclick=\"x", "other"] },
38
- children: [{ type: "text", value: "f" }],
39
- },
40
- { type: "element", tagName: "img", properties: { src: "x", onerror: "alert(1)" }, children: [{ type: "text", value: "y" }] },
41
- { type: "comment", value: "<!-- no -->" },
42
- ],
43
- });
44
- assert.deepEqual(lines, ['<span class="hljs-title function_">f</span>y']);
45
- });
46
-
47
- test("unknown languages fall back to plain text", () => {
48
- const lang = r.resolveLanguage("klingon");
49
- assert.equal(lang.grammar, null);
50
- assert.equal(lang.id, "klingon");
51
- const html = r.renderBlock({ code: "if (x) { y }", language: "klingon" }, { label: false, copy: false, lineNumbers: false });
52
- assert.ok(!html.includes("hljs-"));
53
- assert.ok(html.includes("if (x) { y }"));
54
- assert.equal(r.resolveLanguage("").id, null);
55
- assert.equal(r.resolveLanguage("plaintext").label, null);
56
- });
57
-
58
- test("aliases and editor ids map to grammars", () => {
59
- assert.equal(r.resolveLanguage("ts").grammar, "typescript");
60
- assert.equal(r.resolveLanguage("TSX").grammar, "typescript");
61
- assert.equal(r.resolveLanguage("html").grammar, "xml");
62
- assert.equal(r.resolveLanguage("html").label, "HTML");
63
- assert.equal(r.resolveLanguage("dockerfile").grammar, "dockerfile");
64
- assert.equal(r.resolveLanguage("zig").grammar, null);
65
- assert.equal(r.resolveLanguage("zig").label, "Zig");
66
- });
67
-
68
- test("spans crossing newlines are closed and reopened per line", () => {
69
- const lines = r.highlightLines("/* one\n two */\nx = 1", "javascript");
70
- assert.equal(lines.length, 3);
71
- assert.equal(lines[0], '<span class="hljs-comment">/* one</span>');
72
- assert.equal(lines[1], '<span class="hljs-comment"> two */</span>');
73
- for (const line of lines) {
74
- assert.equal((line.match(/<span/g) ?? []).length, (line.match(/<\/span>/g) ?? []).length, line);
75
- }
76
- });
77
-
78
- test("nested spans split across lines keep their nesting", () => {
79
- const lines = r.hastToLines({
80
- type: "root",
81
- children: [
82
- {
83
- type: "element",
84
- tagName: "span",
85
- properties: { className: ["hljs-string"] },
86
- children: [
87
- { type: "text", value: "`a\n" },
88
- { type: "element", tagName: "span", properties: { className: ["hljs-subst"] }, children: [{ type: "text", value: "${b\nc}" }] },
89
- { type: "text", value: "`" },
90
- ],
91
- },
92
- ],
93
- });
94
- assert.deepEqual(lines, [
95
- '<span class="hljs-string">`a</span>',
96
- '<span class="hljs-string"><span class="hljs-subst">${b</span></span>',
97
- '<span class="hljs-string"><span class="hljs-subst">c}</span>`</span>',
98
- ]);
99
- });
100
-
101
- test("line numbers wrap each line; trailing newline adds no empty line", () => {
102
- const html = r.renderBlock({ code: "a\nb\n", language: "plaintext" }, { label: true, copy: false, lineNumbers: true });
103
- assert.equal((html.match(/class="cw-line"/g) ?? []).length, 2);
104
- assert.ok(!html.includes("cw-code-head"), "plain text has no label");
105
- });
106
-
107
- test("copy button is accessible and optional", () => {
108
- const on = r.renderBlock({ code: "x", language: "js" }, { label: true, copy: true, lineNumbers: false });
109
- assert.ok(on.includes('aria-label="Copy code to clipboard"'));
110
- assert.ok(on.includes('role="status" aria-live="polite"'));
111
- assert.ok(on.includes('<span class="cw-code-label">JavaScript</span>'));
112
- const off = r.renderBlock({ code: "x", language: "js" }, { label: false, copy: false, lineNumbers: false });
113
- assert.ok(!off.includes("button"));
114
- assert.ok(!off.includes("cw-code-label"));
115
- });
116
-
117
- test("very large blocks skip highlighting", () => {
118
- const code = "const x = 1;\n".repeat(Math.ceil(r.MAX_HIGHLIGHT_CHARS / 13) + 1);
119
- assert.ok(!r.highlightLines(code, "javascript").some((l) => l.includes("<span")));
120
- });
121
-
122
- test("any line over the limit skips highlighting, quickly", () => {
123
- const long = `const s = "${"a".repeat(r.MAX_HIGHLIGHT_LINE)}";`;
124
- const started = performance.now();
125
- const lines = r.highlightLines(`let a = 1;\n${long}\nlet b = 2;`, "javascript");
126
- assert.ok(performance.now() - started < 50);
127
- assert.equal(lines.length, 3);
128
- assert.ok(!lines.some((l) => l.includes("<span")));
129
- assert.ok(lines[1]!.includes("&quot;"));
130
- const ok = `const s = "${"a".repeat(r.MAX_HIGHLIGHT_LINE - 20)}";`;
131
- assert.ok(r.highlightLines(ok, "javascript")[0]!.includes("<span"));
132
- const huge = `x = "${"b".repeat(40_000)}"`;
133
- const t2 = performance.now();
134
- r.highlightLines(huge, "typescript");
135
- assert.ok(performance.now() - t2 < 50);
136
- });
137
-
138
- test("rendered blocks are cached and keyed by options and code", () => {
139
- const node = { code: "let a = 1;", language: "js" };
140
- const a = r.renderBlock(node, { label: true, copy: false, lineNumbers: false });
141
- assert.equal(r.renderBlock({ ...node }, { label: true, copy: false, lineNumbers: false }), a);
142
- assert.notEqual(r.renderBlock(node, { label: true, copy: false, lineNumbers: true }), a);
143
- assert.notEqual(r.renderBlock({ ...node, code: "let a = 2;" }, { label: true, copy: false, lineNumbers: false }), a);
144
- });
145
-
146
- test("unknown language ids are capped at 40 characters", () => {
147
- const lang = r.resolveLanguage("x".repeat(100));
148
- assert.equal(lang.id!.length, 40);
149
- const html = r.renderBlock({ code: "y", language: "z".repeat(100) }, { label: false, copy: false, lineNumbers: false });
150
- assert.ok(html.includes(`data-language="${"z".repeat(40)}"`));
151
- assert.ok(!html.includes("z".repeat(41)));
152
- });
@@ -1,114 +0,0 @@
1
- /**
2
- * Run: node --test src/files/*.test.ts
3
- */
4
- import assert from "node:assert/strict";
5
- import { test } from "node:test";
6
-
7
- // @ts-expect-error -- Node's type stripping needs the real .ts extension.
8
- import { createDownloadCounter } from "./counts.ts";
9
- // @ts-expect-error -- Node's type stripping needs the real .ts extension.
10
- import { ifRangeMatches, parseRange, resolveRange } from "./range.ts";
11
-
12
- /** A D1 stand-in that records the (id, n) of each upsert, with a controllable write delay. */
13
- function stubDb(writeMs = 0) {
14
- const writes: Array<Array<[string, number]>> = [];
15
- return {
16
- writes,
17
- prepare: () => ({ bind: (...v: unknown[]) => [v[2] as string, v[3] as number] }),
18
- batch: async (statements: unknown[]) => {
19
- await new Promise((r) => setTimeout(r, writeMs));
20
- writes.push(statements as Array<[string, number]>);
21
- },
22
- };
23
- }
24
-
25
- const total = (writes: Array<Array<[string, number]>>) => {
26
- const out: Record<string, number> = {};
27
- for (const batch of writes) for (const [id, n] of batch) out[id] = (out[id] ?? 0) + n;
28
- return out;
29
- };
30
-
31
- test("counts in a burst are written in one batch", async () => {
32
- const counter = createDownloadCounter("p", "c", 5);
33
- const db = stubDb();
34
- const waits: Promise<unknown>[] = [];
35
- for (let i = 0; i < 3; i++) counter.count(db, "a", (p: Promise<unknown>) => waits.push(p));
36
- counter.count(db, "b", (p: Promise<unknown>) => waits.push(p));
37
- await Promise.all(waits);
38
- assert.equal(db.writes.length, 1);
39
- assert.deepEqual(total(db.writes), { a: 3, b: 1 });
40
- assert.ok(counter.idle);
41
- });
42
-
43
- test("a count made during a write is not lost", async () => {
44
- const counter = createDownloadCounter("p", "c", 5);
45
- const db = stubDb(20);
46
- const waits: Promise<unknown>[] = [];
47
- const waitUntil = (p: Promise<unknown>) => waits.push(p);
48
- counter.count(db, "a", waitUntil);
49
- await new Promise((r) => setTimeout(r, 12)); // First batch is now being written.
50
- counter.count(db, "a", waitUntil);
51
- await Promise.all(waits);
52
- assert.deepEqual(total(db.writes), { a: 2 });
53
- assert.equal(counter.pendingSize, 0);
54
- // After the flush finished, a new count starts a new flush.
55
- counter.count(db, "z", waitUntil);
56
- assert.ok(!counter.idle);
57
- await Promise.all(waits);
58
- assert.deepEqual(total(db.writes), { a: 2, z: 1 });
59
- assert.equal(counter.pendingSize, 0);
60
- });
61
-
62
- test("a failed write doesn't stop later counts", async () => {
63
- const counter = createDownloadCounter("p", "c", 1);
64
- let fail = true;
65
- const db = stubDb();
66
- const batch = db.batch;
67
- db.batch = async (s: unknown[]) => {
68
- if (fail) {
69
- fail = false;
70
- throw new Error("D1 down");
71
- }
72
- return batch(s);
73
- };
74
- const waits: Promise<unknown>[] = [];
75
- const orig = console.error;
76
- console.error = () => undefined;
77
- counter.count(db, "a", (p: Promise<unknown>) => waits.push(p));
78
- await Promise.all(waits);
79
- console.error = orig;
80
- assert.ok(counter.idle);
81
- counter.count(db, "b", (p: Promise<unknown>) => waits.push(p));
82
- await Promise.all(waits);
83
- assert.deepEqual(total(db.writes), { b: 1 });
84
- });
85
-
86
- test("Range parsing: single ranges only", () => {
87
- assert.deepEqual(parseRange("bytes=0-99"), { offset: 0, end: 99 });
88
- assert.deepEqual(parseRange("bytes=100-"), { offset: 100 });
89
- assert.deepEqual(parseRange("bytes=-500"), { suffix: 500 });
90
- assert.equal(parseRange("bytes=0-99,200-299"), null); // Multi-range → whole file (200).
91
- assert.equal(parseRange("bytes=5-1"), null);
92
- assert.equal(parseRange("bytes=-0"), null);
93
- assert.equal(parseRange("items=0-1"), null);
94
- assert.equal(parseRange(null), null);
95
- });
96
-
97
- test("Range resolution clamps to the file", () => {
98
- assert.deepEqual(resolveRange({ suffix: 5000 }, 1000), { offset: 0, length: 1000 });
99
- assert.deepEqual(resolveRange({ suffix: 10 }, 1000), { offset: 990, length: 10 });
100
- assert.deepEqual(resolveRange({ offset: 900, end: 5000 }, 1000), { offset: 900, length: 100 });
101
- assert.deepEqual(resolveRange({ offset: 10 }, 1000), { offset: 10, length: 990 });
102
- assert.equal(resolveRange({ offset: 1000 }, 1000), null);
103
- assert.equal(resolveRange({ suffix: 1 }, 0), null);
104
- });
105
-
106
- test("If-Range", () => {
107
- const modified = new Date("2026-10-01T12:00:00Z");
108
- assert.ok(ifRangeMatches(null, '"abc"', modified));
109
- assert.ok(ifRangeMatches('"abc"', '"abc"', modified));
110
- assert.ok(!ifRangeMatches('"old"', '"abc"', modified));
111
- assert.ok(!ifRangeMatches('W/"abc"', '"abc"', modified));
112
- assert.ok(ifRangeMatches("Thu, 01 Oct 2026 12:00:00 GMT", '"abc"', modified));
113
- assert.ok(!ifRangeMatches("Wed, 30 Sep 2026 12:00:00 GMT", '"abc"', modified));
114
- });