@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
|
@@ -1,522 +0,0 @@
|
|
|
1
|
-
// Unit tests for the Schema & Social graph builders.
|
|
2
|
-
// Run: node --test src/schema/graph.test.mjs (Node 22.6+ strips the types from graph.ts)
|
|
3
|
-
import assert from "node:assert/strict";
|
|
4
|
-
import { test } from "node:test";
|
|
5
|
-
|
|
6
|
-
const g = await import("./graph.ts");
|
|
7
|
-
|
|
8
|
-
const ORIGIN = "https://example.com";
|
|
9
|
-
|
|
10
|
-
function page(overrides = {}) {
|
|
11
|
-
return {
|
|
12
|
-
url: `${ORIGIN}/my-post/`,
|
|
13
|
-
path: "/my-post/",
|
|
14
|
-
locale: null,
|
|
15
|
-
kind: "content",
|
|
16
|
-
pageType: "article",
|
|
17
|
-
title: "My Post | Example",
|
|
18
|
-
pageTitle: "My Post",
|
|
19
|
-
description: "About my post.",
|
|
20
|
-
canonical: `${ORIGIN}/my-post/`,
|
|
21
|
-
image: "/_emdash/api/media/file/01ABC.jpg",
|
|
22
|
-
content: { collection: "posts", id: "e1", slug: "my-post" },
|
|
23
|
-
seo: { ogImage: null, robots: null },
|
|
24
|
-
articleMeta: { publishedTime: "2026-01-02T03:04:05Z", modifiedTime: "2026-02-03T04:05:06Z", author: "Jon Henshaw" },
|
|
25
|
-
siteName: "Example",
|
|
26
|
-
...overrides,
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function publisher(details = { publisherType: "organization", orgRows: [] }) {
|
|
31
|
-
return g.publisherNode({ details, origin: ORIGIN, siteName: "Example", siteLogo: { url: "/_emdash/api/media/file/logo.png", width: 512, height: 512 } });
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function build(p, extra = {}) {
|
|
35
|
-
return g.buildGraph({
|
|
36
|
-
page: p,
|
|
37
|
-
origin: ORIGIN,
|
|
38
|
-
siteName: "Example",
|
|
39
|
-
tagline: "A site",
|
|
40
|
-
language: "en",
|
|
41
|
-
types: {},
|
|
42
|
-
override: null,
|
|
43
|
-
searchUrl: "/?s={search_term_string}",
|
|
44
|
-
publisher: publisher(),
|
|
45
|
-
authors: [],
|
|
46
|
-
image: { url: `${ORIGIN}/_emdash/api/media/file/01ABC.jpg`, width: 1200, height: 630, alt: "A cover" },
|
|
47
|
-
breadcrumbs: false,
|
|
48
|
-
...extra,
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const nodes = (doc) => doc["@graph"];
|
|
53
|
-
const byType = (doc, type) => nodes(doc).filter((n) => n["@type"] === type);
|
|
54
|
-
const byId = (doc, id) => nodes(doc).find((n) => n["@id"] === id);
|
|
55
|
-
|
|
56
|
-
function assertRefsResolve(doc) {
|
|
57
|
-
const ids = new Set(nodes(doc).map((n) => n["@id"]).filter(Boolean));
|
|
58
|
-
const walk = (value, top) => {
|
|
59
|
-
if (Array.isArray(value)) return value.forEach((v) => walk(v, false));
|
|
60
|
-
if (value && typeof value === "object") {
|
|
61
|
-
const keys = Object.keys(value);
|
|
62
|
-
if (!top && keys.length === 1 && keys[0] === "@id") assert.ok(ids.has(value["@id"]), `dangling reference ${value["@id"]}`);
|
|
63
|
-
for (const [k, v] of Object.entries(value)) if (k !== "@id") walk(v, false);
|
|
64
|
-
}
|
|
65
|
-
};
|
|
66
|
-
nodes(doc).forEach((n) => walk(n, true));
|
|
67
|
-
assert.equal(ids.size, nodes(doc).filter((n) => n["@id"]).length, "duplicate @id in graph");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
test("article page: BlogPosting keeps everything core emits", () => {
|
|
71
|
-
const doc = build(page(), { authors: [{ "@type": "Person", "@id": `${ORIGIN}/author/jon/#person`, name: "Jon Henshaw" }] });
|
|
72
|
-
assert.equal(doc["@context"], "https://schema.org");
|
|
73
|
-
const [article] = byType(doc, "BlogPosting");
|
|
74
|
-
assert.ok(article, "BlogPosting node");
|
|
75
|
-
assert.equal(article.headline, "My Post");
|
|
76
|
-
assert.equal(article.description, "About my post.");
|
|
77
|
-
assert.equal(article.datePublished, "2026-01-02T03:04:05Z");
|
|
78
|
-
assert.equal(article.dateModified, "2026-02-03T04:05:06Z");
|
|
79
|
-
assert.deepEqual(article.mainEntityOfPage, { "@id": `${ORIGIN}/my-post/#webpage` });
|
|
80
|
-
assert.deepEqual(article.author, { "@id": `${ORIGIN}/author/jon/#person` });
|
|
81
|
-
assert.deepEqual(article.publisher, { "@id": `${ORIGIN}/#organization` });
|
|
82
|
-
assert.deepEqual(article.image, { "@id": `${ORIGIN}/my-post/#primaryimage` });
|
|
83
|
-
const image = byId(doc, `${ORIGIN}/my-post/#primaryimage`);
|
|
84
|
-
assert.equal(image.width, 1200);
|
|
85
|
-
assert.equal(image.caption, "A cover");
|
|
86
|
-
const [webpage] = byType(doc, "WebPage");
|
|
87
|
-
assert.equal(webpage.url, `${ORIGIN}/my-post/`);
|
|
88
|
-
assert.equal(webpage.inLanguage, "en");
|
|
89
|
-
const [website] = byType(doc, "WebSite");
|
|
90
|
-
assert.equal(website.potentialAction["@type"], "SearchAction");
|
|
91
|
-
assert.equal(website.potentialAction.target.urlTemplate, `${ORIGIN}/?s={search_term_string}`);
|
|
92
|
-
const org = byId(doc, `${ORIGIN}/#organization`);
|
|
93
|
-
assert.equal(org.name, "Example");
|
|
94
|
-
assert.equal(org.logo["@type"], "ImageObject");
|
|
95
|
-
assert.equal(org.logo.url, `${ORIGIN}/_emdash/api/media/file/logo.png`);
|
|
96
|
-
assert.equal(byType(doc, "BreadcrumbList").length, 0, "no breadcrumbs unless enabled");
|
|
97
|
-
assertRefsResolve(doc);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("article page without bylines falls back to the articleMeta author name", () => {
|
|
101
|
-
const doc = build(page());
|
|
102
|
-
const [article] = byType(doc, "BlogPosting");
|
|
103
|
-
assert.deepEqual(article.author, { "@type": "Person", name: "Jon Henshaw" });
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("non-article content page: typed WebPage, no Article", () => {
|
|
107
|
-
const doc = build(page({ pageType: "website", content: { collection: "pages", id: "p1", slug: "about" }, path: "/about/", url: `${ORIGIN}/about/`, canonical: `${ORIGIN}/about/` }), {
|
|
108
|
-
types: { pages: { pageType: "AboutPage" } },
|
|
109
|
-
});
|
|
110
|
-
assert.equal(byType(doc, "AboutPage").length, 1);
|
|
111
|
-
assert.equal(nodes(doc).some((n) => /Article|Posting/.test(String(n["@type"]))), false);
|
|
112
|
-
assertRefsResolve(doc);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test("collection defaults choose the Article subtype", () => {
|
|
116
|
-
const doc = build(page(), { types: { posts: { pageType: "WebPage", articleType: "NewsArticle" } } });
|
|
117
|
-
assert.equal(byType(doc, "NewsArticle").length, 1);
|
|
118
|
-
assert.equal(byType(doc, "BlogPosting").length, 0);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("entry override beats the collection default, and 'none' drops the Article", () => {
|
|
122
|
-
const types = { posts: { articleType: "NewsArticle" } };
|
|
123
|
-
let doc = build(page(), { types, override: { articleType: "TechArticle", pageType: "ItemPage" } });
|
|
124
|
-
assert.equal(byType(doc, "TechArticle").length, 1);
|
|
125
|
-
assert.equal(byType(doc, "ItemPage").length, 1);
|
|
126
|
-
doc = build(page(), { types, override: { articleType: "none" } });
|
|
127
|
-
assert.equal(nodes(doc).some((n) => /Article|Posting/.test(String(n["@type"]))), false);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("home page: WebPage about the publisher, no Article, no breadcrumbs", () => {
|
|
131
|
-
const home = page({ kind: "custom", pageType: "website", content: undefined, path: "/", url: `${ORIGIN}/`, canonical: `${ORIGIN}/`, pageTitle: "Example", articleMeta: undefined, breadcrumbs: undefined });
|
|
132
|
-
const doc = build(home, { breadcrumbs: true });
|
|
133
|
-
const [webpage] = byType(doc, "WebPage");
|
|
134
|
-
assert.deepEqual(webpage.about, { "@id": `${ORIGIN}/#organization` });
|
|
135
|
-
assert.equal(byType(doc, "BreadcrumbList").length, 0);
|
|
136
|
-
assert.equal(nodes(doc).some((n) => /Article|Posting/.test(String(n["@type"]))), false);
|
|
137
|
-
assertRefsResolve(doc);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test("home page type comes from the _home key", () => {
|
|
141
|
-
const home = page({ kind: "custom", pageType: "website", content: undefined, path: "/", url: `${ORIGIN}/`, canonical: `${ORIGIN}/` });
|
|
142
|
-
const doc = build(home, { types: { [g.HOME_KEY]: { pageType: "CollectionPage" } } });
|
|
143
|
-
assert.equal(byType(doc, "CollectionPage").length, 1);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("breadcrumbs: [] means none, undefined derives from the path, explicit trail is verbatim", () => {
|
|
147
|
-
let doc = build(page({ breadcrumbs: [] }), { breadcrumbs: true });
|
|
148
|
-
assert.equal(byType(doc, "BreadcrumbList").length, 0);
|
|
149
|
-
assert.equal(byType(doc, "WebPage")[0].breadcrumb, undefined);
|
|
150
|
-
|
|
151
|
-
doc = build(page({ path: "/guides/my-post/", url: `${ORIGIN}/guides/my-post/`, canonical: `${ORIGIN}/guides/my-post/` }), { breadcrumbs: true });
|
|
152
|
-
const [derived] = byType(doc, "BreadcrumbList");
|
|
153
|
-
assert.deepEqual(
|
|
154
|
-
derived.itemListElement.map((i) => [i.position, i.name, i.item]),
|
|
155
|
-
[
|
|
156
|
-
[1, "Home", `${ORIGIN}/`],
|
|
157
|
-
[2, "Guides", `${ORIGIN}/guides/`],
|
|
158
|
-
[3, "My Post", `${ORIGIN}/guides/my-post/`],
|
|
159
|
-
],
|
|
160
|
-
);
|
|
161
|
-
assert.deepEqual(byType(doc, "WebPage")[0].breadcrumb, { "@id": derived["@id"] });
|
|
162
|
-
|
|
163
|
-
doc = build(page({ breadcrumbs: [{ name: "Home", url: "/" }, { name: "Health", url: "/health/" }] }), { breadcrumbs: true });
|
|
164
|
-
const [explicit] = byType(doc, "BreadcrumbList");
|
|
165
|
-
assert.deepEqual(explicit.itemListElement.map((i) => i.item), [`${ORIGIN}/`, `${ORIGIN}/health/`]);
|
|
166
|
-
assertRefsResolve(doc);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("standalone breadcrumb document when the graph is off", () => {
|
|
170
|
-
assert.equal(g.breadcrumbDocument(page({ breadcrumbs: [] }), ORIGIN), null);
|
|
171
|
-
const doc = g.breadcrumbDocument(page({ path: "/a/b/" }), ORIGIN);
|
|
172
|
-
assert.equal(doc["@type"], "BreadcrumbList");
|
|
173
|
-
assert.equal(doc["@context"], "https://schema.org");
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test("graph has exactly one node per @id (no duplicate primary entities)", () => {
|
|
177
|
-
const author = { "@type": "Person", "@id": `${ORIGIN}/#person`, name: "Jon" };
|
|
178
|
-
const personPublisher = g.publisherNode({
|
|
179
|
-
details: { publisherType: "person", personBylineId: "b1", orgRows: [] },
|
|
180
|
-
origin: ORIGIN,
|
|
181
|
-
siteName: "Example",
|
|
182
|
-
person: { byline: { id: "b1", slug: "jon", displayName: "Jon" }, rows: null },
|
|
183
|
-
});
|
|
184
|
-
assert.equal(personPublisher["@type"], "Person");
|
|
185
|
-
assert.equal(personPublisher["@id"], `${ORIGIN}/#person`);
|
|
186
|
-
const doc = build(page(), { publisher: personPublisher, authors: [author] });
|
|
187
|
-
assert.equal(nodes(doc).filter((n) => n["@id"] === `${ORIGIN}/#person`).length, 1);
|
|
188
|
-
assert.equal(byType(doc, "WebSite").length, 1);
|
|
189
|
-
assert.equal(byType(doc, "BlogPosting").length, 1);
|
|
190
|
-
assertRefsResolve(doc);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("person publisher without a byline still resolves", () => {
|
|
194
|
-
const node = g.publisherNode({ details: { publisherType: "person", orgRows: [] }, origin: ORIGIN, siteName: "Example", person: null });
|
|
195
|
-
assert.equal(node["@type"], "Organization");
|
|
196
|
-
assert.equal(node.name, "Example");
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
test("shapeRows: repeats become arrays, objects get types, relative URLs become absolute", () => {
|
|
200
|
-
const out = g.shapeRows(
|
|
201
|
-
[
|
|
202
|
-
{ prop: "@id", value: "/#org" },
|
|
203
|
-
{ prop: "@id", value: "/#ignored" },
|
|
204
|
-
{ prop: "name", value: "Coywolf" },
|
|
205
|
-
{ prop: "sameAs", value: "https://x.com/coywolf" },
|
|
206
|
-
{ prop: "sameAs", value: "https://github.com/coywolf" },
|
|
207
|
-
{ prop: "logo", value: "/_emdash/api/media/file/logo.png" },
|
|
208
|
-
{ prop: "founder", value: "Jon Henshaw" },
|
|
209
|
-
{ prop: "address", value: { addressLocality: "Austin", addressRegion: "TX", junk: "" } },
|
|
210
|
-
{ prop: "numberOfEmployees", value: "5" },
|
|
211
|
-
{ prop: "url", value: "javascript:alert(1)" },
|
|
212
|
-
{ prop: "description", value: " " },
|
|
213
|
-
],
|
|
214
|
-
"Organization",
|
|
215
|
-
ORIGIN,
|
|
216
|
-
);
|
|
217
|
-
assert.equal(out["@id"], `${ORIGIN}/#org`);
|
|
218
|
-
assert.deepEqual(out.sameAs, ["https://x.com/coywolf", "https://github.com/coywolf"]);
|
|
219
|
-
assert.deepEqual(out.logo, { "@type": "ImageObject", url: `${ORIGIN}/_emdash/api/media/file/logo.png` });
|
|
220
|
-
assert.deepEqual(out.founder, { "@type": "Person", name: "Jon Henshaw" });
|
|
221
|
-
assert.deepEqual(out.address, { "@type": "PostalAddress", addressLocality: "Austin", addressRegion: "TX" });
|
|
222
|
-
assert.deepEqual(out.numberOfEmployees, { "@type": "QuantitativeValue", value: 5 });
|
|
223
|
-
assert.equal(out.url, undefined);
|
|
224
|
-
assert.equal(out.description, undefined);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
test("personNode: rows win, byline fills gaps, author page anchors the id", () => {
|
|
228
|
-
const node = g.personNode({
|
|
229
|
-
byline: { id: "b1", slug: "jon", displayName: "Jon Henshaw", bio: "Writer.", websiteUrl: null, avatarUrl: "/_emdash/api/media/asset/m1/jon.jpg" },
|
|
230
|
-
rows: [
|
|
231
|
-
{ prop: "jobTitle", value: "Founder" },
|
|
232
|
-
{ prop: "sameAs", value: "https://henshaw.social/@jon" },
|
|
233
|
-
],
|
|
234
|
-
origin: ORIGIN,
|
|
235
|
-
defaultId: `${ORIGIN}/author/jon/#person`,
|
|
236
|
-
authorUrl: `${ORIGIN}/author/jon/`,
|
|
237
|
-
});
|
|
238
|
-
assert.equal(node["@id"], `${ORIGIN}/author/jon/#person`);
|
|
239
|
-
assert.equal(node.name, "Jon Henshaw");
|
|
240
|
-
assert.equal(node.jobTitle, "Founder");
|
|
241
|
-
assert.equal(node.url, `${ORIGIN}/author/jon/`);
|
|
242
|
-
assert.equal(node.description, "Writer.");
|
|
243
|
-
assert.equal(node.image, `${ORIGIN}/_emdash/api/media/asset/m1/jon.jpg`);
|
|
244
|
-
assert.equal(g.authorPath("/author/{slug}/", "jon"), "/author/jon/");
|
|
245
|
-
assert.equal(g.authorPath("", "jon"), null);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
test("headline is capped at 110 characters", () => {
|
|
249
|
-
const doc = build(page({ pageTitle: "x".repeat(200) }));
|
|
250
|
-
assert.ok(byType(doc, "BlogPosting")[0].headline.length <= 110);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
test("no SearchAction without a {search_term_string} template", () => {
|
|
254
|
-
const doc = build(page(), { searchUrl: "" });
|
|
255
|
-
assert.equal(byType(doc, "WebSite")[0].potentialAction, undefined);
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
test("robots: defaults, noindex merge, nofollow, explicit values kept", () => {
|
|
259
|
-
const defaults = { maxImagePreview: "large", maxSnippet: -1, maxVideoPreview: -1 };
|
|
260
|
-
assert.equal(g.robotsContent(null, defaults), "index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1");
|
|
261
|
-
assert.equal(g.robotsContent("noindex, nofollow", defaults), "noindex, nofollow");
|
|
262
|
-
assert.equal(g.robotsContent(null, { ...defaults, nofollow: true }), "index, nofollow, max-image-preview:large, max-snippet:-1, max-video-preview:-1");
|
|
263
|
-
assert.equal(g.robotsContent("max-snippet:50", { maxImagePreview: "", maxSnippet: -1 }), "index, follow, max-snippet:50");
|
|
264
|
-
assert.equal(g.robotsContent("none", defaults), "noindex, nofollow");
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
test("og:locale", () => {
|
|
268
|
-
assert.equal(g.ogLocale("en"), "en_US");
|
|
269
|
-
assert.equal(g.ogLocale("en-GB"), "en_GB");
|
|
270
|
-
assert.equal(g.ogLocale("pt-br"), "pt_BR");
|
|
271
|
-
assert.equal(g.ogLocale("fr"), "fr_FR");
|
|
272
|
-
assert.equal(g.ogLocale("zh-Hant-TW"), "zh_TW");
|
|
273
|
-
assert.equal(g.ogLocale(""), null);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
test("media URLs map to lookups; foreign or resized URLs don't", () => {
|
|
277
|
-
assert.deepEqual(g.mediaRefFromUrl("/_emdash/api/media/file/01ABC.jpg", ORIGIN), { by: "storage_key", value: "01ABC.jpg" });
|
|
278
|
-
assert.deepEqual(g.mediaRefFromUrl(`${ORIGIN}/_emdash/api/media/asset/m1/cover.jpg`, ORIGIN), { by: "id", value: "m1" });
|
|
279
|
-
assert.equal(g.mediaRefFromUrl("https://cdn.other.com/_emdash/api/media/file/01ABC.jpg", ORIGIN), null);
|
|
280
|
-
assert.equal(g.mediaRefFromUrl("/_emdash/api/media/file/01ABC.jpg?w=600", ORIGIN), null);
|
|
281
|
-
assert.equal(g.mediaRefFromUrl("/images/x.jpg", ORIGIN), null);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
test("JSON-LD output is plain JSON with no undefined values", () => {
|
|
285
|
-
const doc = build(page({ description: null, articleMeta: {} }), { image: null, tagline: null, language: null });
|
|
286
|
-
const json = JSON.stringify(doc);
|
|
287
|
-
assert.equal(JSON.stringify(JSON.parse(json)), json);
|
|
288
|
-
assert.equal(json.includes("null"), false);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
test("robots limits: blank or missing means -1, never 0", () => {
|
|
292
|
-
assert.equal(g.parseLimit(""), -1);
|
|
293
|
-
assert.equal(g.parseLimit(" "), -1);
|
|
294
|
-
assert.equal(g.parseLimit(undefined), -1);
|
|
295
|
-
assert.equal(g.parseLimit(null), -1);
|
|
296
|
-
assert.equal(g.parseLimit("abc"), -1);
|
|
297
|
-
assert.equal(g.parseLimit("160"), 160);
|
|
298
|
-
assert.equal(g.parseLimit(0), 0);
|
|
299
|
-
assert.equal(g.parseLimit(-5), -1);
|
|
300
|
-
const content = g.robotsContent(null, { maxImagePreview: "large", maxSnippet: g.parseLimit(""), maxVideoPreview: g.parseLimit("") });
|
|
301
|
-
assert.equal(content, "index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1");
|
|
302
|
-
assert.equal(content.includes("max-snippet:0"), false);
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
test("publisher person who also authors is one Person node with merged properties", () => {
|
|
306
|
-
const details = { publisherType: "person", personBylineId: "b1", orgRows: [] };
|
|
307
|
-
const byline = { id: "b1", slug: "jon", displayName: "Jon Henshaw" };
|
|
308
|
-
const pub = g.publisherNode({ details, origin: ORIGIN, siteName: "Example", person: { byline, rows: null } });
|
|
309
|
-
const id = g.authorId({ bylineId: "b1", slug: "jon", origin: ORIGIN, authorUrl: `${ORIGIN}/author/jon/`, details });
|
|
310
|
-
assert.equal(id, pub["@id"]);
|
|
311
|
-
assert.equal(g.authorId({ bylineId: "b2", slug: "amy", origin: ORIGIN, authorUrl: `${ORIGIN}/author/amy/`, details }), `${ORIGIN}/author/amy/#person`);
|
|
312
|
-
assert.equal(g.authorId({ bylineId: "b2", slug: "amy", origin: ORIGIN, authorUrl: null, details }), `${ORIGIN}/#person-amy`);
|
|
313
|
-
const author = g.personNode({ byline, rows: [{ prop: "jobTitle", value: "Editor" }], origin: ORIGIN, defaultId: id });
|
|
314
|
-
const doc = build(page(), { publisher: pub, authors: [author] });
|
|
315
|
-
const people = byType(doc, "Person");
|
|
316
|
-
assert.equal(people.length, 1);
|
|
317
|
-
assert.equal(people[0]["@id"], pub["@id"]);
|
|
318
|
-
assert.equal(people[0].jobTitle, "Editor");
|
|
319
|
-
assert.deepEqual(byType(doc, "BlogPosting")[0].author, { "@id": pub["@id"] });
|
|
320
|
-
assert.equal(pub.jobTitle, undefined, "input publisher node isn't mutated");
|
|
321
|
-
assertRefsResolve(doc);
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
test("publisher uses the chosen organization type and policy URLs", () => {
|
|
325
|
-
const node = g.publisherNode({
|
|
326
|
-
details: {
|
|
327
|
-
publisherType: "organization",
|
|
328
|
-
organizationType: "NewsMediaOrganization",
|
|
329
|
-
orgRows: [
|
|
330
|
-
{ prop: "correctionsPolicy", value: "/policies-and-standards/" },
|
|
331
|
-
{ prop: "parentOrganization", value: { name: "Coywolf LLC", url: "https://coywolf.llc", "@id": "https://coywolf.llc#Organization" } },
|
|
332
|
-
],
|
|
333
|
-
},
|
|
334
|
-
origin: ORIGIN,
|
|
335
|
-
siteName: "Example",
|
|
336
|
-
});
|
|
337
|
-
assert.equal(node["@type"], "NewsMediaOrganization");
|
|
338
|
-
assert.equal(node.correctionsPolicy, `${ORIGIN}/policies-and-standards/`);
|
|
339
|
-
assert.deepEqual(node.parentOrganization, { "@type": "Organization", name: "Coywolf LLC", url: "https://coywolf.llc", "@id": "https://coywolf.llc#Organization" });
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
test("nested copies of graph entities become references; WebPage doesn't repeat the Article", () => {
|
|
343
|
-
const nodes = g.linkNestedEntities([
|
|
344
|
-
{ "@type": "Person", "@id": "https://example.com/author/jon/#person", name: "Jon", url: "https://coywolf.com/jon-henshaw/" },
|
|
345
|
-
{
|
|
346
|
-
"@type": "NewsMediaOrganization",
|
|
347
|
-
"@id": "https://example.com/#organization",
|
|
348
|
-
founder: { "@type": "Person", "@id": "https://coywolf.com/jon-henshaw/#Person", name: "Jon", url: "https://coywolf.com/jon-henshaw/" },
|
|
349
|
-
parentOrganization: { "@type": "Organization", name: "Coywolf LLC", url: "https://coywolf.llc" },
|
|
350
|
-
},
|
|
351
|
-
]);
|
|
352
|
-
assert.deepEqual(nodes[1].founder, { "@id": "https://example.com/author/jon/#person" });
|
|
353
|
-
assert.equal(nodes[1].parentOrganization.name, "Coywolf LLC");
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
test("theme videos become VideoObjects linked from the Article", () => {
|
|
357
|
-
const uid = "3161485099a6f1d5d0fd9e7165b87f37";
|
|
358
|
-
const graph = { "@graph": [
|
|
359
|
-
{ "@type": "WebPage", "@id": `${ORIGIN}/p/#webpage` },
|
|
360
|
-
{ "@type": "Article", "@id": `${ORIGIN}/p/#article` },
|
|
361
|
-
] };
|
|
362
|
-
const v = g.themeVideoNode({ name: "Chemex", description: "", thumbnailUrl: `https://customer-x.cloudflarestream.com/${uid}/thumbnails/thumbnail.jpg`, embedUrl: `https://customer-x.cloudflarestream.com/${uid}/iframe`, uploadDate: "2024-01-01T00:00:00Z", duration: "PT18S" }, ORIGIN);
|
|
363
|
-
assert.equal(v.description, "Chemex");
|
|
364
|
-
assert.equal(g.themeVideoNode({ name: "no urls" }, ORIGIN), null);
|
|
365
|
-
assert.equal(g.themeVideoNode({ embedUrl: "javascript:alert(1)" }, ORIGIN), null);
|
|
366
|
-
g.attachVideos(graph, [v, { ...v }]);
|
|
367
|
-
const videos = graph["@graph"].filter((n) => n["@type"] === "VideoObject");
|
|
368
|
-
assert.equal(videos.length, 1);
|
|
369
|
-
assert.equal(videos[0]["@id"], `${ORIGIN}/p/#video-${uid}`);
|
|
370
|
-
assert.deepEqual(graph["@graph"][1].video, { "@id": `${ORIGIN}/p/#video-${uid}` });
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
test("a guest byline (imported from Coywolf Guest Author) becomes the article's author Person with its website, bio and avatar", () => {
|
|
374
|
-
const byline = { id: "b1", slug: "david-rosenthal", displayName: "David Rosenthal", bio: "Computer scientist.", websiteUrl: "https://blog.dshr.org/", avatarUrl: "/_emdash/api/media/file/abc.jpg" };
|
|
375
|
-
const id = g.authorId({ bylineId: "b1", slug: byline.slug, origin: ORIGIN });
|
|
376
|
-
const node = g.personNode({ byline, origin: ORIGIN, defaultId: id });
|
|
377
|
-
assert.deepEqual(node, {
|
|
378
|
-
"@type": "Person",
|
|
379
|
-
"@id": `${ORIGIN}/#person-david-rosenthal`,
|
|
380
|
-
name: "David Rosenthal",
|
|
381
|
-
url: "https://blog.dshr.org/",
|
|
382
|
-
description: "Computer scientist.",
|
|
383
|
-
image: `${ORIGIN}/_emdash/api/media/file/abc.jpg`,
|
|
384
|
-
});
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
// ── Main subject (WebPage mainEntity) ────────────────────────────
|
|
388
|
-
|
|
389
|
-
const jon = { id: "b1", slug: "jon-henshaw", displayName: "Jon Henshaw", bio: null, websiteUrl: null, avatarUrl: null };
|
|
390
|
-
const jonId = `${ORIGIN}/jon-henshaw/#person`;
|
|
391
|
-
const jonRows = [
|
|
392
|
-
{ prop: "name", value: "Jon Henshaw" },
|
|
393
|
-
{ prop: "jobTitle", value: "Editor" },
|
|
394
|
-
{ prop: "birthDate", value: "1973-09-09", profileOnly: true },
|
|
395
|
-
{ prop: "email", value: "jon@example.com", profileOnly: true },
|
|
396
|
-
{ prop: "birthPlace", value: "Whiteman AFB", profileOnly: true },
|
|
397
|
-
{ prop: "nationality", value: "US", profileOnly: true },
|
|
398
|
-
{ prop: "gender", value: "male", profileOnly: true },
|
|
399
|
-
{ prop: "sameAs", value: "https://henshaw.social/@jon" },
|
|
400
|
-
];
|
|
401
|
-
const jonNode = (profile) => g.personNode({ byline: jon, rows: jonRows, origin: ORIGIN, defaultId: jonId, authorUrl: `${ORIGIN}/jon-henshaw/`, profile });
|
|
402
|
-
const profilePage = (overrides = {}) =>
|
|
403
|
-
page({
|
|
404
|
-
url: `${ORIGIN}/jon-henshaw/`,
|
|
405
|
-
path: "/jon-henshaw/",
|
|
406
|
-
canonical: `${ORIGIN}/jon-henshaw/`,
|
|
407
|
-
pageType: "website",
|
|
408
|
-
pageTitle: "Jon Henshaw",
|
|
409
|
-
content: { collection: "pages", id: "p1", slug: "jon-henshaw" },
|
|
410
|
-
...overrides,
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
test("main subject byline: ProfilePage mainEntity is the Person, with profile-only properties", () => {
|
|
414
|
-
const doc = build(profilePage(), { override: { pageType: "ProfilePage", articleType: "none" }, mainEntity: jonNode(true) });
|
|
415
|
-
const [profile] = byType(doc, "ProfilePage");
|
|
416
|
-
assert.ok(profile, "ProfilePage node");
|
|
417
|
-
assert.deepEqual(profile.mainEntity, { "@id": jonId });
|
|
418
|
-
assert.equal(profile.dateCreated, "2026-01-02T03:04:05Z");
|
|
419
|
-
assert.equal(profile.dateModified, "2026-02-03T04:05:06Z");
|
|
420
|
-
const people = byType(doc, "Person");
|
|
421
|
-
assert.equal(people.length, 1);
|
|
422
|
-
const person = people[0];
|
|
423
|
-
assert.equal(person["@id"], jonId);
|
|
424
|
-
assert.equal(person.birthDate, "1973-09-09");
|
|
425
|
-
assert.equal(person.email, "jon@example.com");
|
|
426
|
-
assert.deepEqual(person.birthPlace, { "@type": "Place", name: "Whiteman AFB" });
|
|
427
|
-
assert.deepEqual(person.nationality, { "@type": "Country", name: "US" });
|
|
428
|
-
assert.equal(person.gender, "male");
|
|
429
|
-
assert.equal(person.jobTitle, "Editor");
|
|
430
|
-
assert.equal(byType(doc, "BlogPosting").length, 0);
|
|
431
|
-
assertRefsResolve(doc);
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
test("article authors never get profile-only properties", () => {
|
|
435
|
-
const author = jonNode(false);
|
|
436
|
-
for (const key of ["birthDate", "email", "birthPlace", "nationality", "gender"]) assert.equal(author[key], undefined, key);
|
|
437
|
-
assert.equal(author.jobTitle, "Editor");
|
|
438
|
-
assert.equal(author["@id"], jonId);
|
|
439
|
-
const doc = build(page(), { authors: [author] });
|
|
440
|
-
const person = byId(doc, jonId);
|
|
441
|
-
assert.equal(person.birthDate, undefined);
|
|
442
|
-
assert.equal(byType(doc, "BlogPosting")[0].author["@id"], jonId);
|
|
443
|
-
assert.equal(byType(doc, "WebPage")[0].mainEntity, undefined);
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
test("main subject who is also the credited author: one Person node, the fuller one", () => {
|
|
447
|
-
const doc = build(profilePage({ pageType: "article" }), { override: { pageType: "ProfilePage" }, authors: [jonNode(false)], mainEntity: jonNode(true) });
|
|
448
|
-
const people = byType(doc, "Person");
|
|
449
|
-
assert.equal(people.length, 1);
|
|
450
|
-
assert.equal(people[0].birthDate, "1973-09-09");
|
|
451
|
-
assert.deepEqual(byType(doc, "BlogPosting")[0].author, { "@id": jonId });
|
|
452
|
-
assert.equal(byType(doc, "ProfilePage")[0].dateCreated, undefined, "the Article carries the dates");
|
|
453
|
-
assertRefsResolve(doc);
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
test("main subject publisher: AboutPage mainEntity is the publisher Organization, no second node", () => {
|
|
457
|
-
const pub = publisher();
|
|
458
|
-
const doc = build(profilePage({ path: "/about/", url: `${ORIGIN}/about/`, canonical: `${ORIGIN}/about/` }), {
|
|
459
|
-
override: { pageType: "AboutPage", articleType: "none" },
|
|
460
|
-
publisher: pub,
|
|
461
|
-
mainEntity: pub,
|
|
462
|
-
});
|
|
463
|
-
const [about] = byType(doc, "AboutPage");
|
|
464
|
-
assert.deepEqual(about.mainEntity, { "@id": `${ORIGIN}/#organization` });
|
|
465
|
-
assert.equal(byType(doc, "Organization").length, 1);
|
|
466
|
-
assertRefsResolve(doc);
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
test("publisher person as main subject gets their profile-only properties on that page only", () => {
|
|
470
|
-
const details = { publisherType: "person", personBylineId: "b1", orgRows: [] };
|
|
471
|
-
const pub = g.publisherNode({ details, origin: ORIGIN, siteName: "Example", person: { byline: jon, rows: jonRows } });
|
|
472
|
-
assert.equal(pub.birthDate, undefined, "publisher node on every page has no profile-only properties");
|
|
473
|
-
const main = g.personNode({ byline: jon, rows: jonRows, origin: ORIGIN, defaultId: String(pub["@id"]), profile: true });
|
|
474
|
-
const doc = build(profilePage(), { override: { pageType: "ProfilePage", articleType: "none" }, publisher: pub, mainEntity: main });
|
|
475
|
-
const people = byType(doc, "Person");
|
|
476
|
-
assert.equal(people.length, 1);
|
|
477
|
-
assert.equal(people[0].birthDate, "1973-09-09");
|
|
478
|
-
assert.deepEqual(byType(doc, "ProfilePage")[0].mainEntity, { "@id": pub["@id"] });
|
|
479
|
-
assertRefsResolve(doc);
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
test("no main subject: output unchanged", () => {
|
|
483
|
-
const base = build(profilePage(), { override: { pageType: "ProfilePage", articleType: "none" } });
|
|
484
|
-
const same = build(profilePage(), { override: { pageType: "ProfilePage", articleType: "none" }, mainEntity: null });
|
|
485
|
-
assert.deepEqual(same, base);
|
|
486
|
-
assert.equal(byType(base, "ProfilePage")[0].mainEntity, undefined);
|
|
487
|
-
assert.equal(byType(base, "Person").length, 0);
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
test("main subject's nested founder/memberOf copies link to graph nodes (no duplicate entities)", () => {
|
|
491
|
-
const pub = g.publisherNode({
|
|
492
|
-
details: { publisherType: "organization", orgRows: [{ prop: "founder", value: { name: "Jon Henshaw", url: `${ORIGIN}/jon-henshaw/` } }] },
|
|
493
|
-
origin: ORIGIN,
|
|
494
|
-
siteName: "Example",
|
|
495
|
-
});
|
|
496
|
-
const main = g.personNode({
|
|
497
|
-
byline: jon,
|
|
498
|
-
rows: [...jonRows, { prop: "url", value: `${ORIGIN}/jon-henshaw/` }, { prop: "memberOf", value: { name: "Example", url: `${ORIGIN}/` } }],
|
|
499
|
-
origin: ORIGIN,
|
|
500
|
-
defaultId: jonId,
|
|
501
|
-
profile: true,
|
|
502
|
-
});
|
|
503
|
-
const doc = build(profilePage(), { override: { pageType: "ProfilePage", articleType: "none" }, publisher: pub, mainEntity: main });
|
|
504
|
-
assert.deepEqual(byId(doc, `${ORIGIN}/#organization`).founder, { "@id": jonId });
|
|
505
|
-
assert.deepEqual(byId(doc, jonId).memberOf, { "@id": `${ORIGIN}/#organization` });
|
|
506
|
-
assertRefsResolve(doc);
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
test("mainSubjectByline parses byline subjects only", () => {
|
|
510
|
-
assert.equal(g.mainSubjectByline("byline:abc"), "abc");
|
|
511
|
-
assert.equal(g.mainSubjectByline("publisher"), null);
|
|
512
|
-
assert.equal(g.mainSubjectByline("byline:"), null);
|
|
513
|
-
assert.equal(g.mainSubjectByline(undefined), null);
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
test("address country becomes a two-letter ISO code", async () => {
|
|
517
|
-
const out = g.shapeRows([{ prop: "address", value: { addressLocality: "Franklin", addressCountry: "USA" } }], "Organization", "https://example.com");
|
|
518
|
-
assert.equal(out.address.addressCountry, "US");
|
|
519
|
-
assert.equal(g.countryCode("United Kingdom"), "GB");
|
|
520
|
-
assert.equal(g.countryCode("de"), "DE");
|
|
521
|
-
assert.equal(g.countryCode("Narnia"), "Narnia");
|
|
522
|
-
});
|