@warpgogol/werkstatt-shared 0.14.0 → 0.14.1
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 +26 -16
- package/README.uk.md +24 -14
- package/package.json +7 -1
- package/src/checks/result-helpers.ts +21 -4
- package/src/checks/tests/remediation-auto-populate.test.ts +83 -0
- package/src/integration/vitest.config.ts +7 -0
- package/src/ontology/manifest.ts +19 -0
- package/src/ontology/schemas/system/tests/growth.test.ts +22 -0
- package/src/ontology/schemas/system/tests/integrations.test.ts +12 -0
- package/src/ontology/schemas/system/tests/manifest.test.ts +22 -0
- package/src/ontology/schemas/system/tests/page-output.test.ts +13 -0
- package/src/ontology/schemas/system/tests/release.test.ts +22 -0
- package/src/ontology/schemas/system/tests/text.test.ts +18 -0
- package/src/ontology/schemas/system/tests/verification.test.ts +12 -0
- package/src/ontology/schemas/tests/biome.test.ts +19 -0
- package/src/ontology/schemas/tests/capability.test.ts +22 -0
- package/src/ontology/schemas/tests/constellation.test.ts +22 -0
- package/src/ontology/schemas/tests/dns-records.test.ts +13 -0
- package/src/ontology/schemas/tests/manifest-resolver.test.ts +13 -0
- package/src/ontology/schemas/tests/page-entry.test.ts +22 -0
- package/src/ontology/schemas/tests/section-archetype.test.ts +12 -0
- package/src/ontology/schemas/tests/site-family.test.ts +18 -0
- package/src/ontology/schemas/tests/system.test.ts +20 -0
- package/src/share/agent/tests/api-catalog.test.ts +8 -0
- package/src/share/agent/tests/ard-catalog.test.ts +8 -0
- package/src/share/agent/tests/capability.test.ts +14 -0
- package/src/share/agent/tests/fleet-catalog.test.ts +15 -0
- package/src/share/agent/tests/knowledge.test.ts +20 -0
- package/src/share/agent/tests/manifest.test.ts +9 -0
- package/src/share/agent/tests/mcp-card.test.ts +8 -0
- package/src/share/agent/tests/omit-empty.test.ts +33 -0
- package/src/share/agent/tests/openapi.test.ts +8 -0
- package/src/share/agent/tests/proof.test.ts +15 -0
- package/src/share/knowledge/tests/claim.test.ts +11 -0
- package/src/share/knowledge/tests/derivation.test.ts +30 -0
- package/src/share/knowledge/tests/freshness.test.ts +18 -0
- package/src/share/knowledge/tests/ledger.test.ts +35 -0
- package/src/share/knowledge/tests/plan.test.ts +19 -0
- package/src/share/knowledge/tests/source.test.ts +19 -0
- package/src/share/remediation/remediation-catalog.ts +196 -0
- package/src/share/schemas/tests/claim-records.test.ts +52 -0
- package/src/share/schemas/tests/claims.test.ts +23 -0
- package/src/share/schemas/tests/effects.test.ts +116 -0
- package/src/share/schemas/tests/features.test.ts +61 -0
- package/src/share/schemas/tests/horizontal-align.test.ts +16 -0
- package/src/share/schemas/tests/icon-color.test.ts +16 -0
- package/src/share/schemas/tests/live-photo.test.ts +40 -0
- package/src/share/schemas/tests/material-credit.test.ts +51 -0
- package/src/share/schemas/tests/media.test.ts +26 -0
- package/src/share/schemas/tests/navigation.test.ts +58 -0
- package/src/share/schemas/tests/page-base.test.ts +19 -0
- package/src/share/schemas/tests/participant.test.ts +26 -0
- package/src/share/schemas/tests/person.test.ts +12 -0
- package/src/share/schemas/tests/print.test.ts +30 -0
- package/src/share/schemas/tests/section-background.test.ts +31 -0
- package/src/share/schemas/tests/section-body.test.ts +16 -0
- package/src/share/schemas/tests/section-cards.test.ts +17 -0
- package/src/share/schemas/tests/section-cta.test.ts +41 -0
- package/src/share/schemas/tests/section-header.test.ts +31 -0
- package/src/share/schemas/tests/section-image.test.ts +24 -0
- package/src/share/schemas/tests/section-motion.test.ts +29 -0
- package/src/share/schemas/tests/section-shell.test.ts +42 -0
- package/src/share/schemas/tests/section-stats.test.ts +17 -0
- package/src/share/schemas/tests/site-background.test.ts +33 -0
- package/src/share/schemas/tests/standard-list-item.test.ts +16 -0
- package/src/share/scripts/tests/external-link-qr.test.ts +8 -0
- package/src/share/scripts/tests/external-links.test.ts +25 -0
- package/src/share/scripts/tests/gsap-counter.test.ts +8 -0
- package/src/share/scripts/tests/gsap-mountain-journey.test.ts +8 -0
- package/src/share/scripts/tests/gsap-parallax.test.ts +8 -0
- package/src/share/scripts/tests/gsap-reveal.test.ts +8 -0
- package/src/share/scripts/tests/gsap-stagger.test.ts +8 -0
- package/src/share/scripts/tests/inline-number-animation.test.ts +8 -0
- package/src/share/scripts/tests/lenis.test.ts +8 -0
- package/src/share/scripts/tests/live-photos.test.ts +8 -0
- package/src/share/scripts/tests/lordicon.test.ts +8 -0
- package/src/share/scripts/tests/orchestrator.test.ts +8 -0
- package/src/share/scripts/tests/scheduler.test.ts +23 -0
- package/src/share/semantic/jsonld/tests/article.test.ts +89 -0
- package/src/share/semantic/jsonld/tests/breadcrumb.test.ts +62 -0
- package/src/share/semantic/jsonld/tests/collection-list.test.ts +39 -0
- package/src/share/semantic/jsonld/tests/faq.test.ts +60 -0
- package/src/share/semantic/jsonld/tests/initiative.test.ts +56 -0
- package/src/share/semantic/jsonld/tests/organization.test.ts +123 -0
- package/src/share/semantic/jsonld/tests/person.test.ts +89 -0
- package/src/share/semantic/jsonld/tests/service.test.ts +65 -0
- package/src/share/semantic/jsonld/tests/shared.test.ts +41 -0
- package/src/share/semantic/jsonld/tests/video.test.ts +84 -0
- package/src/share/semantic/jsonld/tests/webpage.test.ts +93 -0
- package/src/share/semantic/jsonld/tests/website.test.ts +27 -0
- package/src/share/semantic/jsonld/types.ts +1 -0
- package/src/share/semantic/models.ts +1 -0
- package/src/share/semantic/tests/ai.test.ts +55 -0
- package/src/share/semantic/tests/block-extraction.test.ts +34 -0
- package/src/share/semantic/tests/breadcrumbs.test.ts +127 -0
- package/src/share/semantic/tests/build-page.test.ts +122 -0
- package/src/share/semantic/tests/business-projection.test.ts +188 -0
- package/src/share/semantic/tests/derived-prices-loader.test.ts +46 -0
- package/src/share/semantic/tests/extract.test.ts +126 -0
- package/src/share/semantic/tests/feed.test.ts +113 -0
- package/src/share/semantic/tests/helpers.ts +32 -0
- package/src/share/semantic/tests/ids.test.ts +118 -0
- package/src/share/semantic/tests/image-sitemap.test.ts +109 -0
- package/src/share/semantic/tests/jsonld.test.ts +61 -0
- package/src/share/semantic/tests/llms-policy.test.ts +60 -0
- package/src/share/semantic/tests/llms.test.ts +118 -0
- package/src/share/semantic/tests/markdown-hygiene.test.ts +72 -0
- package/src/share/semantic/tests/markdown-page.test.ts +97 -0
- package/src/share/semantic/tests/markdown-twin-provenance.test.ts +152 -0
- package/src/share/semantic/tests/organization-profile.test.ts +107 -0
- package/src/share/semantic/tests/output-projection.test.ts +77 -0
- package/src/share/semantic/tests/page-markdown.test.ts +98 -0
- package/src/share/semantic/tests/page-utils.test.ts +88 -0
- package/src/share/semantic/tests/price-marker-resolver.test.ts +88 -0
- package/src/share/semantic/tests/robots.test.ts +70 -0
- package/src/share/semantic/tests/update-stamp.test.ts +102 -0
- package/src/share/utility-registry.yaml +14 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildJsonFeed, buildRssFeed } from "../feed.ts";
|
|
3
|
+
|
|
4
|
+
const channel = {
|
|
5
|
+
title: "Test Feed",
|
|
6
|
+
url: "https://example.com",
|
|
7
|
+
description: "A test feed",
|
|
8
|
+
language: "de",
|
|
9
|
+
selfUrl: "https://example.com/feed.xml",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const items = [
|
|
13
|
+
{ title: "Item 1", url: "https://example.com/1", summary: "Summary 1", publishedAt: "2024-01-01" },
|
|
14
|
+
{ title: "Item 2", url: "https://example.com/2", summary: "Summary 2", publishedAt: "2024-06-01" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
describe("buildRssFeed", () => {
|
|
18
|
+
it("produces valid RSS XML", () => {
|
|
19
|
+
const result = buildRssFeed(channel, items);
|
|
20
|
+
expect(result).toContain('<?xml version="1.0"');
|
|
21
|
+
expect(result).toContain("<rss version=\"2.0\"");
|
|
22
|
+
expect(result).toContain("<channel>");
|
|
23
|
+
expect(result).toContain("<title>Test Feed</title>");
|
|
24
|
+
expect(result).toContain("<language>de</language>");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("sorts items by publishedAt descending", () => {
|
|
28
|
+
const result = buildRssFeed(channel, items);
|
|
29
|
+
const item1Idx = result.indexOf("Item 1");
|
|
30
|
+
const item2Idx = result.indexOf("Item 2");
|
|
31
|
+
expect(item2Idx).toBeLessThan(item1Idx);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("escapes XML special characters", () => {
|
|
35
|
+
const result = buildRssFeed(channel, [
|
|
36
|
+
{ title: "A & B <C>", url: "https://example.com/x", summary: "S", publishedAt: "2024-01-01" },
|
|
37
|
+
]);
|
|
38
|
+
expect(result).toContain("A & B <C>");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("includes atom self link", () => {
|
|
42
|
+
const result = buildRssFeed(channel, items);
|
|
43
|
+
expect(result).toContain('href="https://example.com/feed.xml"');
|
|
44
|
+
expect(result).toContain('rel="self"');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("derives lastBuildDate from latest item", () => {
|
|
48
|
+
const result = buildRssFeed(channel, items);
|
|
49
|
+
expect(result).toContain("<lastBuildDate>");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("omits lastBuildDate for empty feed", () => {
|
|
53
|
+
const result = buildRssFeed(channel, []);
|
|
54
|
+
expect(result).not.toContain("<lastBuildDate>");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("buildJsonFeed", () => {
|
|
59
|
+
it("produces JSON Feed v1.1", () => {
|
|
60
|
+
const result = buildJsonFeed({
|
|
61
|
+
title: "Test Feed",
|
|
62
|
+
home_page_url: "https://example.com",
|
|
63
|
+
feed_url: "https://example.com/feed.json",
|
|
64
|
+
description: "A test feed",
|
|
65
|
+
language: "de",
|
|
66
|
+
items,
|
|
67
|
+
});
|
|
68
|
+
const parsed = JSON.parse(result);
|
|
69
|
+
expect(parsed.version).toBe("https://jsonfeed.org/version/1.1");
|
|
70
|
+
expect(parsed.title).toBe("Test Feed");
|
|
71
|
+
expect(parsed.items).toHaveLength(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("sorts items by publishedAt descending", () => {
|
|
75
|
+
const result = buildJsonFeed({
|
|
76
|
+
title: "Test",
|
|
77
|
+
home_page_url: "https://example.com",
|
|
78
|
+
feed_url: "https://example.com/feed.json",
|
|
79
|
+
description: "",
|
|
80
|
+
language: "de",
|
|
81
|
+
items,
|
|
82
|
+
});
|
|
83
|
+
const parsed = JSON.parse(result);
|
|
84
|
+
expect(parsed.items[0].title).toBe("Item 2");
|
|
85
|
+
expect(parsed.items[1].title).toBe("Item 1");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("includes date_modified when updatedAt provided", () => {
|
|
89
|
+
const result = buildJsonFeed({
|
|
90
|
+
title: "Test",
|
|
91
|
+
home_page_url: "https://example.com",
|
|
92
|
+
feed_url: "https://example.com/feed.json",
|
|
93
|
+
description: "",
|
|
94
|
+
language: "de",
|
|
95
|
+
items: [{ title: "T", url: "https://example.com/1", summary: "S", publishedAt: "2024-01-01", updatedAt: "2024-06-01" }],
|
|
96
|
+
});
|
|
97
|
+
const parsed = JSON.parse(result);
|
|
98
|
+
expect(parsed.items[0].date_modified).toBe("2024-06-01");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("omits items array for empty feed", () => {
|
|
102
|
+
const result = buildJsonFeed({
|
|
103
|
+
title: "Test",
|
|
104
|
+
home_page_url: "https://example.com",
|
|
105
|
+
feed_url: "https://example.com/feed.json",
|
|
106
|
+
description: "",
|
|
107
|
+
language: "de",
|
|
108
|
+
items: [],
|
|
109
|
+
});
|
|
110
|
+
const parsed = JSON.parse(result);
|
|
111
|
+
expect(parsed.items).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SemanticOrganization, SemanticPageModel, SemanticPageType } from "../models.ts";
|
|
2
|
+
|
|
3
|
+
export function makeOrganization(
|
|
4
|
+
overrides: Partial<SemanticOrganization> = {},
|
|
5
|
+
): SemanticOrganization {
|
|
6
|
+
return {
|
|
7
|
+
name: "Warpgogol",
|
|
8
|
+
description: "Digital infrastructure for nonprofits",
|
|
9
|
+
url: "https://example.com",
|
|
10
|
+
...overrides,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function makePage(
|
|
15
|
+
overrides: Partial<SemanticPageModel> & { type?: SemanticPageType } = {},
|
|
16
|
+
): SemanticPageModel {
|
|
17
|
+
return {
|
|
18
|
+
type: overrides.type ?? "content",
|
|
19
|
+
lang: "de",
|
|
20
|
+
url: "https://example.com/de/page/",
|
|
21
|
+
title: "Test Page",
|
|
22
|
+
description: "A test page description",
|
|
23
|
+
heading: "Test Heading",
|
|
24
|
+
breadcrumbs: [
|
|
25
|
+
{ name: "Home", url: "https://example.com/de/" },
|
|
26
|
+
{ name: "Test Page", url: "https://example.com/de/page/" },
|
|
27
|
+
],
|
|
28
|
+
blocks: [],
|
|
29
|
+
organization: makeOrganization(),
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
canonicalRootUrl,
|
|
4
|
+
createSemanticIds,
|
|
5
|
+
getBaseUrl,
|
|
6
|
+
markdownTwinRelPath,
|
|
7
|
+
markdownTwinUrlPath,
|
|
8
|
+
toAbsoluteUrl,
|
|
9
|
+
toCanonicalUrl,
|
|
10
|
+
toPathname,
|
|
11
|
+
} from "../ids.ts";
|
|
12
|
+
|
|
13
|
+
describe("getBaseUrl", () => {
|
|
14
|
+
it("returns origin from string URL", () => {
|
|
15
|
+
expect(getBaseUrl("https://example.com/de/page")).toBe("https://example.com");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns origin from URL object", () => {
|
|
19
|
+
expect(getBaseUrl(new URL("https://example.com/de/page?q=1"))).toBe("https://example.com");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("toCanonicalUrl", () => {
|
|
24
|
+
it("strips search and hash", () => {
|
|
25
|
+
expect(toCanonicalUrl("https://example.com/de/page?q=1#top")).toBe(
|
|
26
|
+
"https://example.com/de/page",
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("toAbsoluteUrl", () => {
|
|
32
|
+
it("resolves relative path against base", () => {
|
|
33
|
+
expect(toAbsoluteUrl("https://example.com", "/de/page")).toBe("https://example.com/de/page");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("canonicalRootUrl", () => {
|
|
38
|
+
it("returns unprefixed root URL", () => {
|
|
39
|
+
expect(canonicalRootUrl("https://example.com")).toBe("https://example.com/");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("toPathname", () => {
|
|
44
|
+
it("extracts pathname from URL string", () => {
|
|
45
|
+
expect(toPathname("https://example.com/de/page?q=1")).toBe("/de/page");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("markdownTwinRelPath", () => {
|
|
50
|
+
const opts = { supportedLangs: ["de", "uk"] };
|
|
51
|
+
|
|
52
|
+
it("returns index.md for root path", () => {
|
|
53
|
+
expect(markdownTwinRelPath("/", opts)).toBe("index.md");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns lang-root index for language root", () => {
|
|
57
|
+
expect(markdownTwinRelPath("/de/", opts)).toBe("de/index.md");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns nested path as .md file", () => {
|
|
61
|
+
expect(markdownTwinRelPath("/de/about/", opts)).toBe("de/about.md");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("markdownTwinUrlPath", () => {
|
|
66
|
+
it("prefixes rel path with slash", () => {
|
|
67
|
+
expect(markdownTwinUrlPath("/de/about/", { supportedLangs: ["de"] })).toBe("/de/about.md");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("createSemanticIds", () => {
|
|
72
|
+
const ids = createSemanticIds("https://example.com");
|
|
73
|
+
|
|
74
|
+
it("generates organization id", () => {
|
|
75
|
+
expect(ids.organization).toBe("https://example.com/#/schema/organization");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("generates website id", () => {
|
|
79
|
+
expect(ids.website).toBe("https://example.com/#/schema/website");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("generates webpage id from page URL", () => {
|
|
83
|
+
expect(ids.webpage("https://example.com/de/page")).toBe(
|
|
84
|
+
"https://example.com/de/page#/schema/webpage",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("generates breadcrumb id from page URL", () => {
|
|
89
|
+
expect(ids.breadcrumb("https://example.com/de/page")).toBe(
|
|
90
|
+
"https://example.com/de/page#/schema/breadcrumb",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("generates person id with slugified name", () => {
|
|
95
|
+
expect(ids.person("Jane Doe")).toBe("https://example.com/#/schema/person/jane-doe");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("generates initiative id with slugified name", () => {
|
|
99
|
+
expect(ids.initiative("Project Alpha")).toBe(
|
|
100
|
+
"https://example.com/#/schema/initiative/project-alpha",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("generates service id with slugified name", () => {
|
|
105
|
+
expect(ids.service("Web Hosting")).toBe("https://example.com/#/schema/service/web-hosting");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("generates faq id from page URL", () => {
|
|
109
|
+
expect(ids.faq("https://example.com/de/page")).toBe(
|
|
110
|
+
"https://example.com/de/page#/schema/faq",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("strips trailing slash from baseUrl", () => {
|
|
115
|
+
const ids2 = createSemanticIds("https://example.com/");
|
|
116
|
+
expect(ids2.organization).toBe("https://example.com/#/schema/organization");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
generateImageSitemapXml,
|
|
4
|
+
harvestContentImage,
|
|
5
|
+
isHtmlRedirectPage,
|
|
6
|
+
isSyntheticPreviewUrl,
|
|
7
|
+
} from "../image-sitemap.ts";
|
|
8
|
+
|
|
9
|
+
describe("isHtmlRedirectPage", () => {
|
|
10
|
+
it("detects meta-refresh redirect", () => {
|
|
11
|
+
expect(isHtmlRedirectPage('<meta http-equiv="refresh" content="0;url=/de/">')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns false for normal pages", () => {
|
|
15
|
+
expect(isHtmlRedirectPage("<html><body>Hello</body></html>")).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("isSyntheticPreviewUrl", () => {
|
|
20
|
+
it("detects preview screenshots", () => {
|
|
21
|
+
expect(isSyntheticPreviewUrl("https://example.com/preview/page.png")).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("detects og-image fallback", () => {
|
|
25
|
+
expect(isSyntheticPreviewUrl("https://example.com/og-image.png")).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns false for content images", () => {
|
|
29
|
+
expect(isSyntheticPreviewUrl("https://example.com/_astro/img.jpg")).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("harvestContentImage", () => {
|
|
34
|
+
it("extracts canonical URL from link tag", () => {
|
|
35
|
+
const html = '<link rel="canonical" href="https://example.com/de/page" />';
|
|
36
|
+
const result = harvestContentImage(html, "https://example.com");
|
|
37
|
+
expect(result.loc).toBe("https://example.com/de/page");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns null loc when no canonical", () => {
|
|
41
|
+
const result = harvestContentImage("<html></html>", "https://example.com");
|
|
42
|
+
expect(result.loc).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("harvests img with data-content-image attribute", () => {
|
|
46
|
+
const html = '<img src="/_astro/hero.jpg" data-content-image alt="Hero" />';
|
|
47
|
+
const result = harvestContentImage(html, "https://example.com");
|
|
48
|
+
expect(result.imageUrls).toContain("https://example.com/_astro/hero.jpg");
|
|
49
|
+
expect(result.title).toBe("Hero");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("harvests meta x-content-image", () => {
|
|
53
|
+
const html = '<meta name="x-content-image" content="https://example.com/img.jpg" />';
|
|
54
|
+
const result = harvestContentImage(html, "https://example.com");
|
|
55
|
+
expect(result.imageUrls).toContain("https://example.com/img.jpg");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("excludes synthetic preview URLs", () => {
|
|
59
|
+
const html = '<img src="/og-image.png" data-content-image alt="OG" />';
|
|
60
|
+
const result = harvestContentImage(html, "https://example.com");
|
|
61
|
+
expect(result.imageUrls).toHaveLength(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("deduplicates identical URLs", () => {
|
|
65
|
+
const html = `
|
|
66
|
+
<img src="/img.jpg" data-content-image alt="A" />
|
|
67
|
+
<meta name="x-content-image" content="/img.jpg" />
|
|
68
|
+
`;
|
|
69
|
+
const result = harvestContentImage(html, "https://example.com");
|
|
70
|
+
expect(result.imageUrls).toHaveLength(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("falls back to og:image:alt for title", () => {
|
|
74
|
+
const html = `
|
|
75
|
+
<img src="/img.jpg" data-content-image />
|
|
76
|
+
<meta property="og:image:alt" content="OG Alt" />
|
|
77
|
+
`;
|
|
78
|
+
const result = harvestContentImage(html, "https://example.com");
|
|
79
|
+
expect(result.title).toBe("OG Alt");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("generateImageSitemapXml", () => {
|
|
84
|
+
it("produces valid image sitemap XML", () => {
|
|
85
|
+
const result = generateImageSitemapXml([
|
|
86
|
+
{ loc: "https://example.com/page", imageUrl: "https://example.com/img.jpg", title: "Test" },
|
|
87
|
+
]);
|
|
88
|
+
expect(result).toContain('<?xml version="1.0"');
|
|
89
|
+
expect(result).toContain("<urlset");
|
|
90
|
+
expect(result).toContain("xmlns:image=");
|
|
91
|
+
expect(result).toContain("<loc>https://example.com/page</loc>");
|
|
92
|
+
expect(result).toContain("<image:loc>https://example.com/img.jpg</image:loc>");
|
|
93
|
+
expect(result).toContain("<image:title>Test</image:title>");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("produces empty urlset for no entries", () => {
|
|
97
|
+
const result = generateImageSitemapXml([]);
|
|
98
|
+
expect(result).toContain("<urlset");
|
|
99
|
+
expect(result).not.toContain("<url>");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("escapes XML in URLs", () => {
|
|
103
|
+
const result = generateImageSitemapXml([
|
|
104
|
+
{ loc: "https://example.com/a&b", imageUrl: "https://example.com/<img>" },
|
|
105
|
+
]);
|
|
106
|
+
expect(result).toContain("a&b");
|
|
107
|
+
expect(result).toContain("<img>");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildJsonLd } from "../jsonld.ts";
|
|
3
|
+
import { makePage } from "./helpers.ts";
|
|
4
|
+
|
|
5
|
+
describe("buildJsonLd", () => {
|
|
6
|
+
it("produces a valid JSON-LD document with @context and @graph", () => {
|
|
7
|
+
const page = makePage();
|
|
8
|
+
const doc = buildJsonLd(page);
|
|
9
|
+
expect(doc["@context"]).toBe("https://schema.org");
|
|
10
|
+
expect(Array.isArray(doc["@graph"])).toBe(true);
|
|
11
|
+
expect(doc["@graph"].length).toBeGreaterThan(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("includes Organization and WebSite nodes in graph", () => {
|
|
15
|
+
const page = makePage();
|
|
16
|
+
const doc = buildJsonLd(page);
|
|
17
|
+
const types = doc["@graph"].map((n) => n["@type"]);
|
|
18
|
+
expect(types.some((t) => Array.isArray(t) && t.includes("Organization"))).toBe(true);
|
|
19
|
+
expect(types.some((t) => t === "WebSite" || (Array.isArray(t) && t.includes("WebSite")))).toBe(
|
|
20
|
+
true,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("includes WebPage node in graph", () => {
|
|
25
|
+
const page = makePage();
|
|
26
|
+
const doc = buildJsonLd(page);
|
|
27
|
+
const types = doc["@graph"].map((n) => n["@type"]);
|
|
28
|
+
expect(types.some((t) => Array.isArray(t) && t.includes("WebPage"))).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("deduplicates graph nodes by @id", () => {
|
|
32
|
+
const page = makePage();
|
|
33
|
+
const doc = buildJsonLd(page);
|
|
34
|
+
const ids = doc["@graph"].map((n) => n["@id"]).filter(Boolean);
|
|
35
|
+
const uniqueIds = new Set(ids);
|
|
36
|
+
expect(ids.length).toBe(uniqueIds.size);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("includes BreadcrumbList when breadcrumbs > 1", () => {
|
|
40
|
+
const page = makePage({
|
|
41
|
+
breadcrumbs: [
|
|
42
|
+
{ name: "Home", url: "https://example.com/de/" },
|
|
43
|
+
{ name: "About", url: "https://example.com/de/about/" },
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
const doc = buildJsonLd(page);
|
|
47
|
+
const types = doc["@graph"].map((n) => n["@type"]);
|
|
48
|
+
expect(types).toContain("BreadcrumbList");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("includes extraGraphNodes when provided", () => {
|
|
52
|
+
const page = makePage({
|
|
53
|
+
extraGraphNodes: [
|
|
54
|
+
{ "@type": "SoftwareApplication", "@id": "https://example.com/#/schema/app", name: "App" },
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
const doc = buildJsonLd(page);
|
|
58
|
+
const ids = doc["@graph"].map((n) => n["@id"]);
|
|
59
|
+
expect(ids).toContain("https://example.com/#/schema/app");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { LLMS_DEPTH_BY_SEMANTIC_TYPE, resolveLlmsPolicy } from "../llms-policy.ts";
|
|
3
|
+
|
|
4
|
+
describe("resolveLlmsPolicy", () => {
|
|
5
|
+
it("returns full depth by default", () => {
|
|
6
|
+
expect(resolveLlmsPolicy(undefined, "content")).toEqual({ depth: "full" });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("uses type default for openSource", () => {
|
|
10
|
+
expect(resolveLlmsPolicy(undefined, "openSource")).toEqual({ depth: "index-only" });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("uses type default for legal", () => {
|
|
14
|
+
expect(resolveLlmsPolicy(undefined, "legal")).toEqual({ depth: "exclude" });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("accepts string shorthand", () => {
|
|
18
|
+
expect(resolveLlmsPolicy("summary", "content")).toEqual({ depth: "summary" });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("falls back to type default for invalid string depth", () => {
|
|
22
|
+
expect(resolveLlmsPolicy("invalid" as never, "openSource")).toEqual({ depth: "index-only" });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("accepts object form with valid depth", () => {
|
|
26
|
+
expect(resolveLlmsPolicy({ depth: "exclude" }, "content")).toEqual({ depth: "exclude" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("includes section excludes when provided", () => {
|
|
30
|
+
const result = resolveLlmsPolicy(
|
|
31
|
+
{ depth: "summary", sections: { exclude: ["s1", "s2"] } },
|
|
32
|
+
"content",
|
|
33
|
+
);
|
|
34
|
+
expect(result.depth).toBe("summary");
|
|
35
|
+
expect(result.sections?.exclude).toEqual(["s1", "s2"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("filters empty section exclude ids", () => {
|
|
39
|
+
const result = resolveLlmsPolicy(
|
|
40
|
+
{ depth: "summary", sections: { exclude: ["s1", ""] } },
|
|
41
|
+
"content",
|
|
42
|
+
);
|
|
43
|
+
expect(result.sections?.exclude).toEqual(["s1"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("omits sections when no valid excludes", () => {
|
|
47
|
+
const result = resolveLlmsPolicy({ depth: "summary", sections: { exclude: [] } }, "content");
|
|
48
|
+
expect(result.sections).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("LLMS_DEPTH_BY_SEMANTIC_TYPE", () => {
|
|
53
|
+
it("maps openSource to index-only", () => {
|
|
54
|
+
expect(LLMS_DEPTH_BY_SEMANTIC_TYPE.openSource).toBe("index-only");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("maps legal to exclude", () => {
|
|
58
|
+
expect(LLMS_DEPTH_BY_SEMANTIC_TYPE.legal).toBe("exclude");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildLlmsFull, buildLlmsIndex } from "../llms.ts";
|
|
3
|
+
import { makePage } from "./helpers.ts";
|
|
4
|
+
import type { SemanticSiteModel } from "../models.ts";
|
|
5
|
+
|
|
6
|
+
function makeSite(overrides: Partial<SemanticSiteModel> = {}): SemanticSiteModel {
|
|
7
|
+
const page = makePage();
|
|
8
|
+
return {
|
|
9
|
+
organization: page.organization,
|
|
10
|
+
baseUrl: "https://example.com",
|
|
11
|
+
defaultLanguage: "de",
|
|
12
|
+
lang: "de",
|
|
13
|
+
pages: [page],
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("buildLlmsIndex", () => {
|
|
19
|
+
it("produces llms.txt with site name and description", () => {
|
|
20
|
+
const site = makeSite();
|
|
21
|
+
const result = buildLlmsIndex(site);
|
|
22
|
+
expect(result).toContain("# Warpgogol");
|
|
23
|
+
expect(result).toContain("Digital infrastructure for nonprofits");
|
|
24
|
+
expect(result).toContain("## Primary sources");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("includes llms-full.txt reference", () => {
|
|
28
|
+
const site = makeSite();
|
|
29
|
+
const result = buildLlmsIndex(site);
|
|
30
|
+
expect(result).toContain("llms-full.txt");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("includes page links with absolute URLs", () => {
|
|
34
|
+
const site = makeSite();
|
|
35
|
+
const result = buildLlmsIndex(site);
|
|
36
|
+
expect(result).toContain("https://example.com");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("filters out excluded pages from index", () => {
|
|
40
|
+
const page = makePage({
|
|
41
|
+
output: {
|
|
42
|
+
sitemap: { include: true, category: "content", includeLastmod: true },
|
|
43
|
+
llms: { depth: "exclude" },
|
|
44
|
+
robots: { index: true, follow: true },
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const site = makeSite({ pages: [page] });
|
|
48
|
+
const result = buildLlmsIndex(site);
|
|
49
|
+
expect(result).not.toContain("Test Page");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("includes agent discovery links when agent.enabled is not false", () => {
|
|
53
|
+
const site = makeSite();
|
|
54
|
+
const result = buildLlmsIndex(site);
|
|
55
|
+
expect(result).toContain("agent.json");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("omits agent discovery links when agent.enabled is false", () => {
|
|
59
|
+
const site = makeSite({ agent: { enabled: false } } as never);
|
|
60
|
+
const result = buildLlmsIndex(site);
|
|
61
|
+
expect(result).not.toContain("agent.json");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("includes organization facts section", () => {
|
|
65
|
+
const site = makeSite();
|
|
66
|
+
const result = buildLlmsIndex(site);
|
|
67
|
+
expect(result).toContain("## Organization");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("buildLlmsFull", () => {
|
|
72
|
+
it("produces llms-full.txt with site name and description", () => {
|
|
73
|
+
const site = makeSite();
|
|
74
|
+
const result = buildLlmsFull(site);
|
|
75
|
+
expect(result).toContain("# Warpgogol");
|
|
76
|
+
expect(result).toContain("Digital infrastructure for nonprofits");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("includes page sections for full depth pages", () => {
|
|
80
|
+
const site = makeSite();
|
|
81
|
+
const result = buildLlmsFull(site);
|
|
82
|
+
expect(result).toContain("## Test Heading");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("includes page header but not body for summary depth", () => {
|
|
86
|
+
const page = makePage({
|
|
87
|
+
output: {
|
|
88
|
+
sitemap: { include: true, category: "content", includeLastmod: true },
|
|
89
|
+
llms: { depth: "summary" },
|
|
90
|
+
robots: { index: true, follow: true },
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
const site = makeSite({ pages: [page] });
|
|
94
|
+
const result = buildLlmsFull(site);
|
|
95
|
+
expect(result).toContain("## Test Heading");
|
|
96
|
+
expect(result).toContain("URL:");
|
|
97
|
+
expect(result).toContain("Description:");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("filters out excluded pages", () => {
|
|
101
|
+
const page = makePage({
|
|
102
|
+
output: {
|
|
103
|
+
sitemap: { include: true, category: "content", includeLastmod: true },
|
|
104
|
+
llms: { depth: "exclude" },
|
|
105
|
+
robots: { index: true, follow: true },
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
const site = makeSite({ pages: [page] });
|
|
109
|
+
const result = buildLlmsFull(site);
|
|
110
|
+
expect(result).not.toContain("## Test Page");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("includes organization facts section", () => {
|
|
114
|
+
const site = makeSite();
|
|
115
|
+
const result = buildLlmsFull(site);
|
|
116
|
+
expect(result).toContain("## Organization facts");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
canonicalizeGeneratedMarkdownText,
|
|
4
|
+
formatGeneratedMarkdownListItem,
|
|
5
|
+
normalizeGeneratedMarkdownText,
|
|
6
|
+
} from "../markdown-hygiene.ts";
|
|
7
|
+
|
|
8
|
+
describe("normalizeGeneratedMarkdownText", () => {
|
|
9
|
+
it("returns empty string for undefined", () => {
|
|
10
|
+
expect(normalizeGeneratedMarkdownText(undefined)).toBe("");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("normalizes CRLF to LF", () => {
|
|
14
|
+
expect(normalizeGeneratedMarkdownText("a\r\nb")).toBe("a\nb");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("converts <br> tags to newlines", () => {
|
|
18
|
+
expect(normalizeGeneratedMarkdownText("a<br>b")).toBe("a\nb");
|
|
19
|
+
expect(normalizeGeneratedMarkdownText("a<br/>b")).toBe("a\nb");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("converts date slashes to ISO format", () => {
|
|
23
|
+
expect(normalizeGeneratedMarkdownText("2024/1/5")).toBe("2024-01-05");
|
|
24
|
+
expect(normalizeGeneratedMarkdownText("2024/12/15")).toBe("2024-12-15");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("fixes - --- to ---", () => {
|
|
28
|
+
expect(normalizeGeneratedMarkdownText("- ---\n")).toBe("---");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("fixes - - to - ", () => {
|
|
32
|
+
expect(normalizeGeneratedMarkdownText("- - item")).toBe("- item");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("fixes - # to #", () => {
|
|
36
|
+
expect(normalizeGeneratedMarkdownText("- ## Heading")).toBe("## Heading");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("trims trailing whitespace", () => {
|
|
40
|
+
expect(normalizeGeneratedMarkdownText("text \n")).toBe("text");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("canonicalizeGeneratedMarkdownText", () => {
|
|
45
|
+
it("strips default language prefix from URLs", () => {
|
|
46
|
+
const result = canonicalizeGeneratedMarkdownText("Link to [page](https://example.com/de/page)", {
|
|
47
|
+
baseUrl: "https://example.com",
|
|
48
|
+
defaultLanguage: "de",
|
|
49
|
+
});
|
|
50
|
+
expect(result).toBe("Link to [page](https://example.com/page)");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("does not strip when no baseUrl/defaultLanguage", () => {
|
|
54
|
+
const result = canonicalizeGeneratedMarkdownText("https://example.com/de/page");
|
|
55
|
+
expect(result).toBe("https://example.com/de/page");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("formatGeneratedMarkdownListItem", () => {
|
|
60
|
+
it("wraps plain text as list item", () => {
|
|
61
|
+
expect(formatGeneratedMarkdownListItem("Simple text")).toEqual(["- Simple text"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns empty array for empty text", () => {
|
|
65
|
+
expect(formatGeneratedMarkdownListItem("")).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("splits markdown blocks into lines", () => {
|
|
69
|
+
const result = formatGeneratedMarkdownListItem("## Heading\n\nParagraph");
|
|
70
|
+
expect(result).toEqual(["## Heading", "", "Paragraph"]);
|
|
71
|
+
});
|
|
72
|
+
});
|