@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.
- package/README.md +2 -0
- package/docs/README.md +45 -0
- package/docs/ai.md +39 -0
- package/docs/backups.md +88 -0
- package/docs/breadcrumbs.md +59 -0
- package/docs/code-blocks.md +21 -0
- package/docs/content-urls.md +41 -0
- package/docs/custom-blocks.md +27 -0
- package/docs/discovery.md +32 -0
- package/docs/files.md +44 -0
- package/docs/form-uploads.md +59 -0
- package/docs/headings.md +37 -0
- package/docs/images.md +141 -0
- package/docs/internals.md +13 -0
- package/docs/links.md +34 -0
- package/docs/performance.md +72 -0
- package/docs/redirects.md +84 -0
- package/docs/releasing.md +21 -0
- package/docs/reviews.md +89 -0
- package/docs/robots.md +81 -0
- package/docs/schema.md +79 -0
- package/docs/search.md +114 -0
- package/docs/videos.md +48 -0
- package/docs/wordpress-import.md +198 -0
- package/package.json +11 -2
- package/src/version.ts +1 -1
- package/src/ai/logic.test.ts +0 -187
- package/src/ai/queue.test.ts +0 -97
- package/src/ai/wikidata.test.ts +0 -184
- package/src/backups/dump.test.mjs +0 -287
- package/src/backups/restore.test.mjs +0 -103
- package/src/codeBlocks/render.test.ts +0 -152
- package/src/files/counts.test.ts +0 -114
- package/src/files/format.test.ts +0 -93
- package/src/files/r2-rules.test.ts +0 -30
- package/src/files/sigv4.test.ts +0 -64
- package/src/headings/headings.test.ts +0 -159
- package/src/pageCache/lib.test.mjs +0 -224
- package/src/pageCache/warm.test.mjs +0 -670
- package/src/redirects/slug-core.test.ts +0 -50
- package/src/robots/rep.test.ts +0 -335
- package/src/robots/rules.test.ts +0 -251
- package/src/schema/graph.test.mjs +0 -522
package/src/files/format.test.ts
DELETED
|
@@ -1,93 +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 * as f from "./format.ts";
|
|
9
|
-
// @ts-expect-error -- Node's type stripping needs the real .ts extension.
|
|
10
|
-
import { entryTitle, fileIdsIn, findFileBlocks } from "./walker.ts";
|
|
11
|
-
|
|
12
|
-
test("Content-Disposition: plain ASCII names", () => {
|
|
13
|
-
assert.equal(f.contentDisposition("report.pdf"), 'attachment; filename="report.pdf"');
|
|
14
|
-
assert.equal(f.contentDisposition("Q3 report (final).pdf"), 'attachment; filename="Q3 report (final).pdf"');
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test("Content-Disposition: RFC 5987 for non-ASCII and unsafe characters", () => {
|
|
18
|
-
assert.equal(f.contentDisposition("résumé.pdf"), `attachment; filename="resume.pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf`);
|
|
19
|
-
assert.equal(f.contentDisposition("日本語.txt"), `attachment; filename="___.txt"; filename*=UTF-8''%E6%97%A5%E6%9C%AC%E8%AA%9E.txt`);
|
|
20
|
-
assert.equal(f.contentDisposition('say "hi".txt'), `attachment; filename="say _hi_.txt"; filename*=UTF-8''say%20%22hi%22.txt`);
|
|
21
|
-
assert.equal(f.contentDisposition("it's (1)*.txt"), `attachment; filename="it's (1)*.txt"`);
|
|
22
|
-
assert.equal(f.contentDisposition("50%.txt"), `attachment; filename="50_.txt"; filename*=UTF-8''50%25.txt`);
|
|
23
|
-
assert.equal(f.contentDisposition("a'bé.txt"), `attachment; filename="a'be.txt"; filename*=UTF-8''a%27b%C3%A9.txt`);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("Content-Disposition: header injection and paths", () => {
|
|
27
|
-
assert.equal(f.contentDisposition("evil\r\nSet-Cookie: x.pdf"), `attachment; filename="evil Set-Cookie: x.pdf"`);
|
|
28
|
-
assert.equal(f.contentDisposition("../../etc/passwd"), `attachment; filename="passwd"`);
|
|
29
|
-
assert.equal(f.contentDisposition(""), `attachment; filename="download"`);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("safe file names and download paths", () => {
|
|
33
|
-
assert.equal(f.safeFilename("My Résumé (2026).PDF"), "My-Resume-2026.PDF");
|
|
34
|
-
assert.equal(f.safeFilename("../x/y z.tar.gz"), "y-z.tar.gz");
|
|
35
|
-
assert.equal(f.safeFilename("???"), "file");
|
|
36
|
-
assert.equal(f.downloadPath("download", "fabcdefghijklmno", "a b.pdf"), "/download/fabcdefghijklmno/a-b.pdf");
|
|
37
|
-
assert.equal(f.normalizeBase("/Files/"), "files");
|
|
38
|
-
assert.equal(f.normalizeBase("_emdash"), "download");
|
|
39
|
-
assert.equal(f.normalizeBase("a/b"), "download");
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("type badge mapping", () => {
|
|
43
|
-
assert.deepEqual(f.iconFor("pdf"), { color: "#B42318", label: "PDF" });
|
|
44
|
-
assert.deepEqual(f.iconFor("DOCX"), { color: "#155EEF", label: "DOCX" });
|
|
45
|
-
assert.deepEqual(f.iconFor("xlsx"), { color: "#067647", label: "XLSX" });
|
|
46
|
-
assert.deepEqual(f.iconFor("jpeg"), { color: "#C11574", label: "JPEG" });
|
|
47
|
-
assert.deepEqual(f.iconFor("7z"), { color: "#6941E0", label: "7Z" });
|
|
48
|
-
assert.deepEqual(f.iconFor("numbers"), { color: "#067647", label: "NUMB" });
|
|
49
|
-
assert.deepEqual(f.iconFor("weird"), { color: f.DEFAULT_ICON_COLOR, label: "WEIR" });
|
|
50
|
-
assert.deepEqual(f.iconFor(""), { color: f.DEFAULT_ICON_COLOR, label: "FILE" });
|
|
51
|
-
assert.equal(f.extensionOf("archive.TAR.GZ"), "gz");
|
|
52
|
-
assert.equal(f.extensionOf("README"), "");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("sizes, colors and ids", () => {
|
|
56
|
-
assert.equal(f.formatSize(0), "0 B");
|
|
57
|
-
assert.equal(f.formatSize(1023), "1023 B");
|
|
58
|
-
assert.equal(f.formatSize(1536), "1.5 KB");
|
|
59
|
-
assert.equal(f.formatSize(5 * 1024 ** 3), "5.0 GB");
|
|
60
|
-
assert.equal(f.safeColor("#0a7"), "#0a7");
|
|
61
|
-
assert.equal(f.safeColor("red;}body{"), "");
|
|
62
|
-
const id = f.newUploadId();
|
|
63
|
-
assert.match(id, f.UPLOAD_ID);
|
|
64
|
-
assert.ok(f.isFileId("01J9ZQ3W4X5Y6Z7A8B9C0D1E2F"));
|
|
65
|
-
assert.ok(!f.isFileId("../etc"));
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("Portable Text walker finds nested coywolf-file blocks", () => {
|
|
69
|
-
const content = {
|
|
70
|
-
id: "c1",
|
|
71
|
-
slug: "hello",
|
|
72
|
-
data: {
|
|
73
|
-
title: "Hello",
|
|
74
|
-
body: [
|
|
75
|
-
{ _type: "block", children: [{ _type: "span", text: "x" }] },
|
|
76
|
-
{ _type: "coywolf-file", _key: "a", id: "fabcdefghijklmno", title: "Brochure" },
|
|
77
|
-
{ _type: "columns", columns: [{ content: [{ _type: "coywolf-file", id: "01J9ZQ3W4X5Y6Z7A8B9C0D1E2F" }] }] },
|
|
78
|
-
{ _type: "coywolf-file", id: "" },
|
|
79
|
-
{ _type: "image", id: "01J9ZQ3W4X5Y6Z7A8B9C0D1E2G" },
|
|
80
|
-
],
|
|
81
|
-
sidebar: [{ _type: "coywolf-file", id: "fabcdefghijklmno" }],
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
assert.deepEqual(findFileBlocks(content), [
|
|
85
|
-
{ id: "fabcdefghijklmno", field: "body" },
|
|
86
|
-
{ id: "01J9ZQ3W4X5Y6Z7A8B9C0D1E2F", field: "body" },
|
|
87
|
-
{ id: "fabcdefghijklmno", field: "sidebar" },
|
|
88
|
-
]);
|
|
89
|
-
assert.deepEqual(fileIdsIn(content), ["fabcdefghijklmno", "01J9ZQ3W4X5Y6Z7A8B9C0D1E2F"]);
|
|
90
|
-
assert.equal(entryTitle(content), "Hello");
|
|
91
|
-
assert.equal(entryTitle({ id: "x", slug: "s", data: {} }), "s");
|
|
92
|
-
assert.deepEqual(fileIdsIn(null), []);
|
|
93
|
-
});
|
|
@@ -1,30 +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 { parseDownloadPath } from "./format.ts";
|
|
9
|
-
// @ts-expect-error -- Node's type stripping needs the real .ts extension.
|
|
10
|
-
import { MAX_THROUGH_SITE, PART_SIZE, partSizeFor } from "./r2-rules.ts";
|
|
11
|
-
|
|
12
|
-
const MiB = 1024 * 1024;
|
|
13
|
-
|
|
14
|
-
test("parts are 6 MiB: above R2's 5 MiB minimum, under EmDash's 8 MiB route body cap", () => {
|
|
15
|
-
assert.equal(PART_SIZE, 6 * MiB);
|
|
16
|
-
assert.equal(partSizeFor(1), PART_SIZE);
|
|
17
|
-
assert.equal(partSizeFor(40 * 1024 * MiB), PART_SIZE);
|
|
18
|
-
assert.equal(MAX_THROUGH_SITE, 10_000 * PART_SIZE);
|
|
19
|
-
assert.ok(MAX_THROUGH_SITE >= 50 * 1024 ** 3, "the 50 GB setting fits");
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("download paths", () => {
|
|
23
|
-
assert.deepEqual(parseDownloadPath("/download/fabcdefghijklmno/report.pdf", "download"), { id: "fabcdefghijklmno", filename: "report.pdf" });
|
|
24
|
-
assert.deepEqual(parseDownloadPath("/download/01J9ZQ3W4X5Y6Z7A8B9C0D1E2F", "download"), { id: "01J9ZQ3W4X5Y6Z7A8B9C0D1E2F", filename: null });
|
|
25
|
-
assert.equal(parseDownloadPath("/download/", "download"), null);
|
|
26
|
-
assert.equal(parseDownloadPath("/download/not-an-id/x.pdf", "download"), null);
|
|
27
|
-
assert.equal(parseDownloadPath("/download/fabcdefghijklmno/a/b", "download"), null);
|
|
28
|
-
assert.equal(parseDownloadPath("/downloads/fabcdefghijklmno/x", "download"), null);
|
|
29
|
-
assert.equal(parseDownloadPath("/download/%E0%A4%A/x", "download"), null);
|
|
30
|
-
});
|
package/src/files/sigv4.test.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SigV4 against the examples AWS publishes in the S3 API reference
|
|
3
|
-
* ("Signature Calculations for the Authorization Header" and "Authenticating
|
|
4
|
-
* Requests: Using Query Parameters").
|
|
5
|
-
* Run: node --test src/files/*.test.ts
|
|
6
|
-
*/
|
|
7
|
-
import assert from "node:assert/strict";
|
|
8
|
-
import { test } from "node:test";
|
|
9
|
-
|
|
10
|
-
// @ts-expect-error -- Node's type stripping needs the real .ts extension.
|
|
11
|
-
import { canonicalQuery, presignUrl, sha256Hex, signRequest, uriEncode } from "./sigv4.ts";
|
|
12
|
-
|
|
13
|
-
const creds = { accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" };
|
|
14
|
-
const date = new Date("2013-05-24T00:00:00Z");
|
|
15
|
-
const host = "examplebucket.s3.amazonaws.com";
|
|
16
|
-
const base = { host, region: "us-east-1", date };
|
|
17
|
-
|
|
18
|
-
test("GET object (Range header)", async () => {
|
|
19
|
-
const { signature, headers } = await signRequest({ ...base, method: "GET", path: "/test.txt", headers: { range: "bytes=0-9" } }, creds);
|
|
20
|
-
assert.equal(signature, "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41");
|
|
21
|
-
assert.match(headers.authorization, /^AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE\/20130524\/us-east-1\/s3\/aws4_request,SignedHeaders=host;range;x-amz-content-sha256;x-amz-date,Signature=f0e8/);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test("PUT object (encoded key, signed body)", async () => {
|
|
25
|
-
const body = "Welcome to Amazon S3.";
|
|
26
|
-
const payload = await sha256Hex(body);
|
|
27
|
-
assert.equal(payload, "44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072");
|
|
28
|
-
const { signature } = await signRequest(
|
|
29
|
-
{
|
|
30
|
-
...base,
|
|
31
|
-
method: "PUT",
|
|
32
|
-
path: "/test$file.text",
|
|
33
|
-
headers: { date: "Fri, 24 May 2013 00:00:00 GMT", "x-amz-storage-class": "REDUCED_REDUNDANCY" },
|
|
34
|
-
},
|
|
35
|
-
creds,
|
|
36
|
-
payload,
|
|
37
|
-
);
|
|
38
|
-
assert.equal(signature, "98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd");
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("GET bucket lifecycle (valueless query parameter)", async () => {
|
|
42
|
-
const { signature } = await signRequest({ ...base, method: "GET", path: "/", query: { lifecycle: "" } }, creds);
|
|
43
|
-
assert.equal(signature, "fea454ca298b7da1c68078a5d1bdbfbbe0d65c699e0f91ac7a200a0136783543");
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test("GET bucket list (sorted query)", async () => {
|
|
47
|
-
const { signature } = await signRequest({ ...base, method: "GET", path: "/", query: { prefix: "J", "max-keys": "2" } }, creds);
|
|
48
|
-
assert.equal(signature, "34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("presigned GET URL", async () => {
|
|
52
|
-
const { url, signature } = await presignUrl({ ...base, method: "GET", path: "/test.txt" }, creds, 86400);
|
|
53
|
-
assert.equal(signature, "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404");
|
|
54
|
-
assert.equal(
|
|
55
|
-
url,
|
|
56
|
-
"https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404",
|
|
57
|
-
);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("URI encoding and query order", () => {
|
|
61
|
-
assert.equal(uriEncode("a b/ü~*", false), "a%20b/%C3%BC~%2A");
|
|
62
|
-
assert.equal(uriEncode("a/b"), "a%2Fb");
|
|
63
|
-
assert.equal(canonicalQuery({ uploadId: "x y", partNumber: "2" }), "partNumber=2&uploadId=x%20y");
|
|
64
|
-
});
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
// Run: node --import ./src/headings/test-hooks.mjs --test src/headings/headings.test.ts
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { test } from "node:test";
|
|
4
|
-
|
|
5
|
-
import { blockText, slugify, uniqueSlug, validAnchor } from "./slug.js";
|
|
6
|
-
import { stampContent } from "./stamp.js";
|
|
7
|
-
import { buildTocTree, countToc } from "./toc.js";
|
|
8
|
-
import { normalizeSettings, withLegacyBreadcrumbs } from "./settings.js";
|
|
9
|
-
|
|
10
|
-
const h = (key: string, style: string, text: string, extra: Record<string, unknown> = {}) => ({
|
|
11
|
-
_type: "block",
|
|
12
|
-
_key: key,
|
|
13
|
-
style,
|
|
14
|
-
children: [{ _type: "span", _key: `${key}s`, text, marks: [] }],
|
|
15
|
-
markDefs: [],
|
|
16
|
-
...extra,
|
|
17
|
-
});
|
|
18
|
-
const p = (key: string, text: string) => h(key, "normal", text);
|
|
19
|
-
const opts = { anchors: true, toc: true, prefix: "jump-" };
|
|
20
|
-
|
|
21
|
-
test("slugify mirrors sanitize_title", () => {
|
|
22
|
-
assert.equal(slugify("Hello, World!"), "hello-world");
|
|
23
|
-
assert.equal(slugify("Don't Panic"), "dont-panic");
|
|
24
|
-
assert.equal(slugify(" Café au lait "), "cafe-au-lait");
|
|
25
|
-
assert.equal(slugify("Price & Plans — 2026"), "price-plans-2026");
|
|
26
|
-
assert.equal(slugify("snake_case words"), "snake-case-words");
|
|
27
|
-
assert.equal(slugify("日本語 見出し"), "日本語-見出し");
|
|
28
|
-
assert.equal(slugify("!!!"), "");
|
|
29
|
-
assert.ok(slugify("a".repeat(200)).length <= 80);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("uniqueSlug prefixes and de-duplicates", () => {
|
|
33
|
-
const taken = new Set<string>();
|
|
34
|
-
assert.equal(uniqueSlug("Intro", taken), "jump-intro");
|
|
35
|
-
assert.equal(uniqueSlug("Intro", taken), "jump-intro-2");
|
|
36
|
-
assert.equal(uniqueSlug("Intro", taken), "jump-intro-3");
|
|
37
|
-
assert.equal(uniqueSlug("???", taken), "jump-section");
|
|
38
|
-
assert.equal(uniqueSlug("2026 plans", new Set(), ""), "h-2026-plans");
|
|
39
|
-
assert.equal(uniqueSlug("Intro", new Set(), "sec-"), "sec-intro");
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("validAnchor rejects unsafe ids", () => {
|
|
43
|
-
assert.ok(validAnchor("jump-intro"));
|
|
44
|
-
assert.ok(!validAnchor('x" onclick="y'));
|
|
45
|
-
assert.ok(!validAnchor("1abc"));
|
|
46
|
-
assert.ok(!validAnchor(""));
|
|
47
|
-
assert.ok(!validAnchor(42));
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("blockText reads nested marks trees", () => {
|
|
51
|
-
const node = { children: [{ _type: "@span", children: [{ _type: "@text", text: "Bold " }] }, { _type: "@text", text: "tail" }] };
|
|
52
|
-
assert.equal(blockText(node), "Bold tail");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("stamping adds unique anchors and TOC headings (breadcrumb blocks are left to Breadcrumb Nav)", () => {
|
|
56
|
-
const content = {
|
|
57
|
-
title: "My Post",
|
|
58
|
-
body: [
|
|
59
|
-
{ _type: "coywolf-toc", _key: "t" },
|
|
60
|
-
{ _type: "coywolf-breadcrumbs", _key: "b" },
|
|
61
|
-
h("a", "h2", "Intro"),
|
|
62
|
-
p("x", "text"),
|
|
63
|
-
h("b2", "h3", "Intro"),
|
|
64
|
-
h("c", "h1", "Title"),
|
|
65
|
-
h("d", "h2", ""),
|
|
66
|
-
],
|
|
67
|
-
other: "not portable text",
|
|
68
|
-
};
|
|
69
|
-
const out = stampContent(content, opts);
|
|
70
|
-
assert.ok(out);
|
|
71
|
-
const body = out.body as Record<string, unknown>[];
|
|
72
|
-
assert.equal(body[2].anchor, "jump-intro");
|
|
73
|
-
assert.equal(body[4].anchor, "jump-intro-2");
|
|
74
|
-
assert.equal(body[5].anchor, undefined, "H1 is not anchored");
|
|
75
|
-
assert.equal(body[6].anchor, undefined, "empty heading is skipped");
|
|
76
|
-
assert.deepEqual(body[0]._headings, [
|
|
77
|
-
{ level: 2, id: "jump-intro", text: "Intro" },
|
|
78
|
-
{ level: 3, id: "jump-intro-2", text: "Intro" },
|
|
79
|
-
]);
|
|
80
|
-
assert.equal(body[1]._title, undefined);
|
|
81
|
-
assert.equal(out.other, "not portable text");
|
|
82
|
-
// Input untouched.
|
|
83
|
-
assert.equal((content.body[2] as Record<string, unknown>).anchor, undefined);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("stamping is idempotent", () => {
|
|
87
|
-
const content = { title: "T", body: [{ _type: "coywolf-toc", _key: "t" }, h("a", "h2", "One"), h("b", "h2", "One")] };
|
|
88
|
-
const once = stampContent(content, opts);
|
|
89
|
-
assert.ok(once);
|
|
90
|
-
assert.equal(stampContent(once, opts), null);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("anchors stay stable when the editor drops them and the heading is reworded", () => {
|
|
94
|
-
const saved = stampContent({ body: [h("a", "h2", "Pricing"), h("b", "h2", "FAQ")] }, opts);
|
|
95
|
-
assert.ok(saved);
|
|
96
|
-
// Editor round trip: anchor field gone, first heading renamed, a new heading inserted before it.
|
|
97
|
-
const edited = { body: [h("n", "h2", "FAQ"), h("a", "h2", "Plans and pricing"), h("b", "h2", "FAQ")] };
|
|
98
|
-
const out = stampContent(edited, { ...opts, previous: saved });
|
|
99
|
-
assert.ok(out);
|
|
100
|
-
const body = out.body as Record<string, unknown>[];
|
|
101
|
-
assert.equal(body[1].anchor, "jump-pricing", "kept by _key despite new text");
|
|
102
|
-
assert.equal(body[2].anchor, "jump-faq", "kept by _key");
|
|
103
|
-
assert.equal(body[0].anchor, "jump-faq-2", "new heading slugs around existing anchors");
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("anchors fall back to text matching when keys change", () => {
|
|
107
|
-
const saved = stampContent({ body: [h("a", "h2", "Setup")] }, { ...opts, prefix: "old-" });
|
|
108
|
-
assert.ok(saved);
|
|
109
|
-
const out = stampContent({ body: [h("z", "h2", "Setup")] }, { ...opts, previous: saved });
|
|
110
|
-
assert.equal((out?.body as Record<string, unknown>[])[0].anchor, "old-setup");
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test("invalid stored anchors are replaced", () => {
|
|
114
|
-
const out = stampContent({ body: [h("a", "h2", "Hi", { anchor: '"><script>' })] }, opts);
|
|
115
|
-
assert.equal((out?.body as Record<string, unknown>[])[0].anchor, "jump-hi");
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("TOC tree nests by level and tolerates skipped levels", () => {
|
|
119
|
-
const flat = [
|
|
120
|
-
{ level: 2, id: "a", text: "A" },
|
|
121
|
-
{ level: 4, id: "a1", text: "A1" },
|
|
122
|
-
{ level: 3, id: "a2", text: "A2" },
|
|
123
|
-
{ level: 2, id: "b", text: "B" },
|
|
124
|
-
{ level: 5, id: "deep", text: "Deep" },
|
|
125
|
-
{ level: 3, id: "b1", text: "B1" },
|
|
126
|
-
];
|
|
127
|
-
const tree = buildTocTree(flat, [2, 3, 4]);
|
|
128
|
-
assert.deepEqual(
|
|
129
|
-
tree.map((n) => [n.id, n.children.map((c) => [c.id, c.children.map((g) => g.id)])]),
|
|
130
|
-
[
|
|
131
|
-
["a", [["a1", []], ["a2", []]]],
|
|
132
|
-
["b", [["b1", []]]],
|
|
133
|
-
],
|
|
134
|
-
);
|
|
135
|
-
assert.equal(countToc(tree), 5);
|
|
136
|
-
assert.deepEqual(
|
|
137
|
-
buildTocTree(flat, [3]).map((n) => n.id),
|
|
138
|
-
["a2", "b1"],
|
|
139
|
-
);
|
|
140
|
-
assert.deepEqual(
|
|
141
|
-
buildTocTree([{ level: 3, id: "x", text: "X" }, { level: 2, id: "y", text: "Y" }], [2, 3]).map((n) => n.id),
|
|
142
|
-
["x", "y"],
|
|
143
|
-
);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("TOC title can be hidden; levels can be H2 only", () => {
|
|
147
|
-
assert.equal(normalizeSettings({}).toc.showTitle, true);
|
|
148
|
-
assert.equal(normalizeSettings({ toc: { showTitle: false } }).toc.showTitle, false);
|
|
149
|
-
assert.deepEqual(normalizeSettings({ toc: { levels: [2] } }).toc.levels, [2]);
|
|
150
|
-
assert.equal("breadcrumbs" in normalizeSettings({ breadcrumbs: { homeLabel: "Start" } }), false);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test("saving Headings settings keeps the legacy breadcrumb sub-object for Breadcrumb Nav", () => {
|
|
154
|
-
const legacy = { separator: "chevron", homeLabel: "Start" };
|
|
155
|
-
const next = normalizeSettings({ prefix: "sec-" });
|
|
156
|
-
assert.deepEqual(withLegacyBreadcrumbs({ prefix: "jump-", breadcrumbs: legacy }, next), { ...next, breadcrumbs: legacy });
|
|
157
|
-
assert.deepEqual(withLegacyBreadcrumbs({ prefix: "jump-" }, next), next);
|
|
158
|
-
assert.deepEqual(withLegacyBreadcrumbs(null, next), next);
|
|
159
|
-
});
|
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
// Unit tests for page cache purge rules.
|
|
2
|
-
// Run: node --test src/pageCache/lib.test.mjs (Node 22.6+ strips the types from lib.ts)
|
|
3
|
-
import assert from "node:assert/strict";
|
|
4
|
-
import { test } from "node:test";
|
|
5
|
-
|
|
6
|
-
const { purgesAfter, watchInvalidation } = await import("./lib.ts");
|
|
7
|
-
const api = "/_emdash/api/plugins/coywolf-pack/";
|
|
8
|
-
|
|
9
|
-
test("admin writes to pack routes purge the page cache", () => {
|
|
10
|
-
for (const route of ["schema/site/save", "redirects/import", "features/save", "robots/save", "videos/webhook", "videos/update", "backups/run"]) {
|
|
11
|
-
assert.equal(purgesAfter("POST", api + route), true, route);
|
|
12
|
-
}
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
test("public pack routes, reads, and other plugins don't", () => {
|
|
16
|
-
for (const route of ["search/live", "search/index", "discovery/public/llms", "videos/like", "videos/play", "videos/caption", "videos/embed", "videos/sitemap"]) {
|
|
17
|
-
assert.equal(purgesAfter("POST", api + route), false, route);
|
|
18
|
-
}
|
|
19
|
-
assert.equal(purgesAfter("GET", api + "schema/config"), false);
|
|
20
|
-
assert.equal(purgesAfter("POST", "/_emdash/api/plugins/emdash-forms/submit"), false);
|
|
21
|
-
assert.equal(purgesAfter("POST", "/_emdash/api/content/posts"), false, "EmDash purges its own content by tag");
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
const { applyPageLifetime } = await import("./lib.ts");
|
|
25
|
-
|
|
26
|
-
function routeCache(maxAge) {
|
|
27
|
-
return {
|
|
28
|
-
enabled: true,
|
|
29
|
-
options: maxAge === undefined ? {} : { maxAge },
|
|
30
|
-
sets: [],
|
|
31
|
-
set(o) {
|
|
32
|
-
this.sets.push(o);
|
|
33
|
-
},
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
test("page lifetimes apply only to routes the site made cacheable", () => {
|
|
38
|
-
const page = routeCache(3600);
|
|
39
|
-
assert.equal(applyPageLifetime(page, 7, 1), true);
|
|
40
|
-
assert.deepEqual(page.sets, [{ maxAge: 604800, swr: 86400 }]);
|
|
41
|
-
|
|
42
|
-
const uncached = routeCache(undefined);
|
|
43
|
-
assert.equal(applyPageLifetime(uncached, 7, 1), false, "never turns caching on");
|
|
44
|
-
assert.deepEqual(uncached.sets, []);
|
|
45
|
-
assert.equal(applyPageLifetime(undefined, 7, 1), false);
|
|
46
|
-
assert.equal(applyPageLifetime({ ...routeCache(60), enabled: false }, 7, 1), false);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const { settlePage, STOPGAP_LIFETIME } = await import("./lib.ts");
|
|
50
|
-
|
|
51
|
-
const html = (body = "<p>hi</p>", init = {}) => new Response(body, { headers: { "content-type": "text/html; charset=utf-8" }, ...init });
|
|
52
|
-
/** settlePage with no failed read: the stopgap checks alone. */
|
|
53
|
-
const shortenStopgapPage = (cache, response, stopgap, markHeader) => settlePage(cache, response, { failed: () => false, stopgap, markHeader });
|
|
54
|
-
|
|
55
|
-
test("a page rendered with a stopgap poster is cached for minutes, decided after its body has rendered", async () => {
|
|
56
|
-
const page = routeCache(604800);
|
|
57
|
-
let rendered = false;
|
|
58
|
-
// The flag is set while the body streams (a component deep in the page), not before next() returns.
|
|
59
|
-
const stream = new ReadableStream({
|
|
60
|
-
async pull(controller) {
|
|
61
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
62
|
-
rendered = true;
|
|
63
|
-
controller.enqueue(new TextEncoder().encode("<video>"));
|
|
64
|
-
controller.close();
|
|
65
|
-
},
|
|
66
|
-
});
|
|
67
|
-
const out = await shortenStopgapPage(page, html(stream, { headers: { "content-type": "text/html", "x-keep": "1" } }), () => rendered);
|
|
68
|
-
assert.deepEqual(page.sets, [{ maxAge: STOPGAP_LIFETIME.maxAge, swr: STOPGAP_LIFETIME.swr }]);
|
|
69
|
-
assert.equal(await out.text(), "<video>");
|
|
70
|
-
assert.equal(out.headers.get("x-keep"), "1");
|
|
71
|
-
assert.equal(out.status, 200);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("pages without a stopgap, uncached routes and non-HTML responses keep their lifetime", async () => {
|
|
75
|
-
const page = routeCache(604800);
|
|
76
|
-
assert.equal(await (await shortenStopgapPage(page, html(), () => false)).text(), "<p>hi</p>");
|
|
77
|
-
assert.deepEqual(page.sets, []);
|
|
78
|
-
|
|
79
|
-
const untouched = html();
|
|
80
|
-
assert.equal(await shortenStopgapPage(routeCache(undefined), untouched, () => assert.fail("asked")), untouched);
|
|
81
|
-
assert.equal(await shortenStopgapPage(undefined, untouched, () => assert.fail("asked")), untouched);
|
|
82
|
-
|
|
83
|
-
const json = new Response("{}", { headers: { "content-type": "application/json" } });
|
|
84
|
-
assert.equal(await shortenStopgapPage(routeCache(60), json, () => assert.fail("asked")), json);
|
|
85
|
-
const missing = html("gone", { status: 404 });
|
|
86
|
-
assert.equal(await shortenStopgapPage(routeCache(60), missing, () => assert.fail("asked")), missing);
|
|
87
|
-
|
|
88
|
-
const short = routeCache(120);
|
|
89
|
-
await shortenStopgapPage(short, html(), () => true);
|
|
90
|
-
assert.deepEqual(short.sets, [{ maxAge: 120, swr: STOPGAP_LIFETIME.swr }], "never lengthens a shorter lifetime");
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("the cache warmer's stopgap render isn't cached and is marked for a revisit; visitors' renders aren't marked", async () => {
|
|
94
|
-
const warmer = routeCache(604800);
|
|
95
|
-
const marked = await shortenStopgapPage(warmer, html("<video>", { headers: { "content-type": "text/html", "x-keep": "1" } }), () => true, "X-Coywolf-Stopgap");
|
|
96
|
-
assert.deepEqual(warmer.sets, [false], "not cached, so the mark never reaches visitors and the revisit renders afresh");
|
|
97
|
-
assert.equal(marked.headers.get("x-coywolf-stopgap"), "1");
|
|
98
|
-
assert.equal(marked.headers.get("x-keep"), "1");
|
|
99
|
-
assert.equal(await marked.text(), "<video>");
|
|
100
|
-
|
|
101
|
-
const visitor = routeCache(604800);
|
|
102
|
-
const plain = await shortenStopgapPage(visitor, html(), () => true);
|
|
103
|
-
assert.equal(plain.headers.get("x-coywolf-stopgap"), null);
|
|
104
|
-
assert.deepEqual(visitor.sets, [{ maxAge: STOPGAP_LIFETIME.maxAge, swr: STOPGAP_LIFETIME.swr }]);
|
|
105
|
-
|
|
106
|
-
const fine = routeCache(604800);
|
|
107
|
-
const clean = await shortenStopgapPage(fine, html(), () => false, "X-Coywolf-Stopgap");
|
|
108
|
-
assert.equal(clean.headers.get("x-coywolf-stopgap"), null, "the warmer's normal renders aren't marked");
|
|
109
|
-
assert.deepEqual(fine.sets, []);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("watchInvalidation notices EmDash clearing pages by tag, and only a successful clear", async () => {
|
|
113
|
-
const calls = [];
|
|
114
|
-
const cache = {
|
|
115
|
-
async invalidate(options) {
|
|
116
|
-
calls.push(options);
|
|
117
|
-
if (options.tags.includes("boom")) throw new Error("purge failed");
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
const invalidated = watchInvalidation(cache);
|
|
121
|
-
assert.equal(invalidated(), false, "a draft save that clears nothing");
|
|
122
|
-
await assert.rejects(cache.invalidate({ tags: ["boom"] }));
|
|
123
|
-
assert.equal(invalidated(), false);
|
|
124
|
-
await cache.invalidate({ tags: ["posts", "01ABC"] });
|
|
125
|
-
assert.equal(invalidated(), true);
|
|
126
|
-
assert.deepEqual(calls.at(-1), { tags: ["posts", "01ABC"] }, "the route's call still reaches the cache");
|
|
127
|
-
assert.equal(watchInvalidation(undefined)(), false, "no route cache");
|
|
128
|
-
assert.equal(watchInvalidation({})(), false);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
// ── After a failed read (src/core/read-failures.ts) ─────────────
|
|
132
|
-
|
|
133
|
-
// Astro's own route cache (what context.cache is in production), so set(false) and a later
|
|
134
|
-
// set() behave as they really do: a later set() turns caching back on.
|
|
135
|
-
const { AstroCache, applyCacheHeaders, isCacheActive } = await import(new URL("../../node_modules/astro/dist/core/cache/runtime/cache.js", import.meta.url).href);
|
|
136
|
-
const PROVIDER = { setHeaders: (options) => new Headers({ "Cloudflare-CDN-Cache-Control": options.maxAge === undefined ? "public" : `public, max-age=${options.maxAge}` }) };
|
|
137
|
-
function astroCache(maxAge = 604800) {
|
|
138
|
-
const cache = new AstroCache(PROVIDER);
|
|
139
|
-
cache.set({ maxAge, swr: 86400 });
|
|
140
|
-
return cache;
|
|
141
|
-
}
|
|
142
|
-
/** The CDN header Astro would send for this cache state. */
|
|
143
|
-
const cdnHeader = (cache) => {
|
|
144
|
-
const response = new Response(null);
|
|
145
|
-
applyCacheHeaders(cache, response, new Request("https://example.com/"));
|
|
146
|
-
return response.headers.get("Cloudflare-CDN-Cache-Control");
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
test("Astro's cache: a set() after set(false) turns caching back on (why settlePage decides last)", () => {
|
|
150
|
-
const cache = astroCache();
|
|
151
|
-
cache.set(false);
|
|
152
|
-
assert.equal(isCacheActive(cache), false);
|
|
153
|
-
cache.set({ tags: ["emdash:menu:primary"] });
|
|
154
|
-
assert.equal(isCacheActive(cache), true);
|
|
155
|
-
assert.equal(cdnHeader(cache), "public");
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
test("a page whose render saw a failed read isn't cached, decided after its body (a component's late cache.set can't undo it)", async () => {
|
|
159
|
-
const cache = astroCache();
|
|
160
|
-
let failed = false;
|
|
161
|
-
// The layout renders while the body streams: a settings read fails, then the menu adds its cache tag.
|
|
162
|
-
const stream = new ReadableStream({
|
|
163
|
-
async pull(controller) {
|
|
164
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
165
|
-
failed = true;
|
|
166
|
-
cache.set({ tags: ["emdash:menu:primary"] });
|
|
167
|
-
controller.enqueue(new TextEncoder().encode("<main></main>"));
|
|
168
|
-
controller.close();
|
|
169
|
-
},
|
|
170
|
-
});
|
|
171
|
-
const out = await settlePage(cache, html(stream, { headers: { "content-type": "text/html", "x-keep": "1" } }), { failed: () => failed, label: "GET /a/" });
|
|
172
|
-
assert.equal(isCacheActive(cache), false);
|
|
173
|
-
assert.equal(cdnHeader(cache), null, "no Cloudflare-CDN-Cache-Control: the adapter sends no-store");
|
|
174
|
-
assert.equal(await out.text(), "<main></main>", "the visitor still gets the page");
|
|
175
|
-
assert.equal(out.headers.get("x-keep"), "1");
|
|
176
|
-
assert.equal(out.status, 200);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
test("a failed read wins over a stopgap; the cache warmer is told to come back", async () => {
|
|
180
|
-
const visitor = astroCache();
|
|
181
|
-
await settlePage(visitor, html(), { failed: () => true, stopgap: () => true });
|
|
182
|
-
assert.equal(isCacheActive(visitor), false, "not cached for minutes either");
|
|
183
|
-
|
|
184
|
-
const warmer = astroCache();
|
|
185
|
-
const marked = await settlePage(warmer, html(), { failed: () => true, markHeader: "X-Coywolf-Stopgap" });
|
|
186
|
-
assert.equal(isCacheActive(warmer), false);
|
|
187
|
-
assert.equal(marked.headers.get("x-coywolf-stopgap"), "1", "visited again later (src/pageCache/warm.ts)");
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("other responses after a failed read: left out of the cache without reading their body", async () => {
|
|
191
|
-
for (const response of [
|
|
192
|
-
new Response("User-agent: *\nAllow: /\n", { headers: { "content-type": "text/plain" } }), // EmDash's robots.txt
|
|
193
|
-
new Response("{}", { headers: { "content-type": "application/json" } }),
|
|
194
|
-
html("<p>later</p>", { status: 503 }),
|
|
195
|
-
new Response(null, { status: 200, headers: { "content-type": "text/html" } }), // HEAD
|
|
196
|
-
]) {
|
|
197
|
-
const cache = astroCache();
|
|
198
|
-
const out = await settlePage(cache, response, { failed: () => true });
|
|
199
|
-
assert.equal(out, response, "returned as is, still streaming");
|
|
200
|
-
assert.equal(response.bodyUsed, false);
|
|
201
|
-
assert.equal(isCacheActive(cache), false, response.headers.get("content-type"));
|
|
202
|
-
}
|
|
203
|
-
// A route that only had cache tags (no lifetime) is still left out.
|
|
204
|
-
const tagged = new AstroCache(PROVIDER);
|
|
205
|
-
tagged.set({ tags: ["posts"] });
|
|
206
|
-
await settlePage(tagged, html(), { failed: () => true });
|
|
207
|
-
assert.equal(isCacheActive(tagged), false);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
test("no failed read: a cacheable page keeps its lifetime, and an uncached route is never turned on", async () => {
|
|
211
|
-
const cache = astroCache();
|
|
212
|
-
const out = await settlePage(cache, html(), { failed: () => false });
|
|
213
|
-
assert.equal(await out.text(), "<p>hi</p>");
|
|
214
|
-
assert.equal(cdnHeader(cache), "public, max-age=604800");
|
|
215
|
-
|
|
216
|
-
const off = astroCache();
|
|
217
|
-
off.set(false); // e.g. a 404 the site turned off
|
|
218
|
-
await settlePage(off, html("gone", { status: 404 }), { failed: () => false });
|
|
219
|
-
assert.equal(isCacheActive(off), false);
|
|
220
|
-
|
|
221
|
-
const untouched = html();
|
|
222
|
-
assert.equal(await settlePage(undefined, untouched, { failed: () => assert.fail("asked") }), untouched, "no route cache (dev)");
|
|
223
|
-
assert.equal(await settlePage({ enabled: false, set: () => assert.fail("set") }, untouched, { failed: () => assert.fail("asked") }), untouched);
|
|
224
|
-
});
|