@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.
Files changed (117) hide show
  1. package/README.md +26 -16
  2. package/README.uk.md +24 -14
  3. package/package.json +7 -1
  4. package/src/checks/result-helpers.ts +21 -4
  5. package/src/checks/tests/remediation-auto-populate.test.ts +83 -0
  6. package/src/integration/vitest.config.ts +7 -0
  7. package/src/ontology/manifest.ts +19 -0
  8. package/src/ontology/schemas/system/tests/growth.test.ts +22 -0
  9. package/src/ontology/schemas/system/tests/integrations.test.ts +12 -0
  10. package/src/ontology/schemas/system/tests/manifest.test.ts +22 -0
  11. package/src/ontology/schemas/system/tests/page-output.test.ts +13 -0
  12. package/src/ontology/schemas/system/tests/release.test.ts +22 -0
  13. package/src/ontology/schemas/system/tests/text.test.ts +18 -0
  14. package/src/ontology/schemas/system/tests/verification.test.ts +12 -0
  15. package/src/ontology/schemas/tests/biome.test.ts +19 -0
  16. package/src/ontology/schemas/tests/capability.test.ts +22 -0
  17. package/src/ontology/schemas/tests/constellation.test.ts +22 -0
  18. package/src/ontology/schemas/tests/dns-records.test.ts +13 -0
  19. package/src/ontology/schemas/tests/manifest-resolver.test.ts +13 -0
  20. package/src/ontology/schemas/tests/page-entry.test.ts +22 -0
  21. package/src/ontology/schemas/tests/section-archetype.test.ts +12 -0
  22. package/src/ontology/schemas/tests/site-family.test.ts +18 -0
  23. package/src/ontology/schemas/tests/system.test.ts +20 -0
  24. package/src/share/agent/tests/api-catalog.test.ts +8 -0
  25. package/src/share/agent/tests/ard-catalog.test.ts +8 -0
  26. package/src/share/agent/tests/capability.test.ts +14 -0
  27. package/src/share/agent/tests/fleet-catalog.test.ts +15 -0
  28. package/src/share/agent/tests/knowledge.test.ts +20 -0
  29. package/src/share/agent/tests/manifest.test.ts +9 -0
  30. package/src/share/agent/tests/mcp-card.test.ts +8 -0
  31. package/src/share/agent/tests/omit-empty.test.ts +33 -0
  32. package/src/share/agent/tests/openapi.test.ts +8 -0
  33. package/src/share/agent/tests/proof.test.ts +15 -0
  34. package/src/share/knowledge/tests/claim.test.ts +11 -0
  35. package/src/share/knowledge/tests/derivation.test.ts +30 -0
  36. package/src/share/knowledge/tests/freshness.test.ts +18 -0
  37. package/src/share/knowledge/tests/ledger.test.ts +35 -0
  38. package/src/share/knowledge/tests/plan.test.ts +19 -0
  39. package/src/share/knowledge/tests/source.test.ts +19 -0
  40. package/src/share/remediation/remediation-catalog.ts +196 -0
  41. package/src/share/schemas/tests/claim-records.test.ts +52 -0
  42. package/src/share/schemas/tests/claims.test.ts +23 -0
  43. package/src/share/schemas/tests/effects.test.ts +116 -0
  44. package/src/share/schemas/tests/features.test.ts +61 -0
  45. package/src/share/schemas/tests/horizontal-align.test.ts +16 -0
  46. package/src/share/schemas/tests/icon-color.test.ts +16 -0
  47. package/src/share/schemas/tests/live-photo.test.ts +40 -0
  48. package/src/share/schemas/tests/material-credit.test.ts +51 -0
  49. package/src/share/schemas/tests/media.test.ts +26 -0
  50. package/src/share/schemas/tests/navigation.test.ts +58 -0
  51. package/src/share/schemas/tests/page-base.test.ts +19 -0
  52. package/src/share/schemas/tests/participant.test.ts +26 -0
  53. package/src/share/schemas/tests/person.test.ts +12 -0
  54. package/src/share/schemas/tests/print.test.ts +30 -0
  55. package/src/share/schemas/tests/section-background.test.ts +31 -0
  56. package/src/share/schemas/tests/section-body.test.ts +16 -0
  57. package/src/share/schemas/tests/section-cards.test.ts +17 -0
  58. package/src/share/schemas/tests/section-cta.test.ts +41 -0
  59. package/src/share/schemas/tests/section-header.test.ts +31 -0
  60. package/src/share/schemas/tests/section-image.test.ts +24 -0
  61. package/src/share/schemas/tests/section-motion.test.ts +29 -0
  62. package/src/share/schemas/tests/section-shell.test.ts +42 -0
  63. package/src/share/schemas/tests/section-stats.test.ts +17 -0
  64. package/src/share/schemas/tests/site-background.test.ts +33 -0
  65. package/src/share/schemas/tests/standard-list-item.test.ts +16 -0
  66. package/src/share/scripts/tests/external-link-qr.test.ts +8 -0
  67. package/src/share/scripts/tests/external-links.test.ts +25 -0
  68. package/src/share/scripts/tests/gsap-counter.test.ts +8 -0
  69. package/src/share/scripts/tests/gsap-mountain-journey.test.ts +8 -0
  70. package/src/share/scripts/tests/gsap-parallax.test.ts +8 -0
  71. package/src/share/scripts/tests/gsap-reveal.test.ts +8 -0
  72. package/src/share/scripts/tests/gsap-stagger.test.ts +8 -0
  73. package/src/share/scripts/tests/inline-number-animation.test.ts +8 -0
  74. package/src/share/scripts/tests/lenis.test.ts +8 -0
  75. package/src/share/scripts/tests/live-photos.test.ts +8 -0
  76. package/src/share/scripts/tests/lordicon.test.ts +8 -0
  77. package/src/share/scripts/tests/orchestrator.test.ts +8 -0
  78. package/src/share/scripts/tests/scheduler.test.ts +23 -0
  79. package/src/share/semantic/jsonld/tests/article.test.ts +89 -0
  80. package/src/share/semantic/jsonld/tests/breadcrumb.test.ts +62 -0
  81. package/src/share/semantic/jsonld/tests/collection-list.test.ts +39 -0
  82. package/src/share/semantic/jsonld/tests/faq.test.ts +60 -0
  83. package/src/share/semantic/jsonld/tests/initiative.test.ts +56 -0
  84. package/src/share/semantic/jsonld/tests/organization.test.ts +123 -0
  85. package/src/share/semantic/jsonld/tests/person.test.ts +89 -0
  86. package/src/share/semantic/jsonld/tests/service.test.ts +65 -0
  87. package/src/share/semantic/jsonld/tests/shared.test.ts +41 -0
  88. package/src/share/semantic/jsonld/tests/video.test.ts +84 -0
  89. package/src/share/semantic/jsonld/tests/webpage.test.ts +93 -0
  90. package/src/share/semantic/jsonld/tests/website.test.ts +27 -0
  91. package/src/share/semantic/jsonld/types.ts +1 -0
  92. package/src/share/semantic/models.ts +1 -0
  93. package/src/share/semantic/tests/ai.test.ts +55 -0
  94. package/src/share/semantic/tests/block-extraction.test.ts +34 -0
  95. package/src/share/semantic/tests/breadcrumbs.test.ts +127 -0
  96. package/src/share/semantic/tests/build-page.test.ts +122 -0
  97. package/src/share/semantic/tests/business-projection.test.ts +188 -0
  98. package/src/share/semantic/tests/derived-prices-loader.test.ts +46 -0
  99. package/src/share/semantic/tests/extract.test.ts +126 -0
  100. package/src/share/semantic/tests/feed.test.ts +113 -0
  101. package/src/share/semantic/tests/helpers.ts +32 -0
  102. package/src/share/semantic/tests/ids.test.ts +118 -0
  103. package/src/share/semantic/tests/image-sitemap.test.ts +109 -0
  104. package/src/share/semantic/tests/jsonld.test.ts +61 -0
  105. package/src/share/semantic/tests/llms-policy.test.ts +60 -0
  106. package/src/share/semantic/tests/llms.test.ts +118 -0
  107. package/src/share/semantic/tests/markdown-hygiene.test.ts +72 -0
  108. package/src/share/semantic/tests/markdown-page.test.ts +97 -0
  109. package/src/share/semantic/tests/markdown-twin-provenance.test.ts +152 -0
  110. package/src/share/semantic/tests/organization-profile.test.ts +107 -0
  111. package/src/share/semantic/tests/output-projection.test.ts +77 -0
  112. package/src/share/semantic/tests/page-markdown.test.ts +98 -0
  113. package/src/share/semantic/tests/page-utils.test.ts +88 -0
  114. package/src/share/semantic/tests/price-marker-resolver.test.ts +88 -0
  115. package/src/share/semantic/tests/robots.test.ts +70 -0
  116. package/src/share/semantic/tests/update-stamp.test.ts +102 -0
  117. package/src/share/utility-registry.yaml +14 -0
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildBreadcrumbTrail,
4
+ stripSiteNameFromTitle,
5
+ surfaceAncestorPageIds,
6
+ type BreadcrumbAncestorResolver,
7
+ } from "../breadcrumbs.ts";
8
+
9
+ describe("stripSiteNameFromTitle", () => {
10
+ it("strips site name suffix", () => {
11
+ expect(stripSiteNameFromTitle("About | Warpgogol")).toBe("About");
12
+ });
13
+
14
+ it("returns title unchanged when no separator", () => {
15
+ expect(stripSiteNameFromTitle("About")).toBe("About");
16
+ });
17
+
18
+ it("trims whitespace", () => {
19
+ expect(stripSiteNameFromTitle(" About ")).toBe("About");
20
+ });
21
+ });
22
+
23
+ describe("surfaceAncestorPageIds", () => {
24
+ it("returns empty for non-surface pageId", () => {
25
+ expect(surfaceAncestorPageIds("some-page")).toEqual([]);
26
+ });
27
+
28
+ it("returns empty for depth-0 root landing", () => {
29
+ expect(surfaceAncestorPageIds("industry:_root")).toEqual([]);
30
+ });
31
+
32
+ it("returns root + intermediate ancestors for depth-2", () => {
33
+ expect(surfaceAncestorPageIds("industry:tech:saas")).toEqual([
34
+ "industry:_root",
35
+ "industry:tech",
36
+ ]);
37
+ });
38
+ });
39
+
40
+ describe("buildBreadcrumbTrail", () => {
41
+ const noopResolver: BreadcrumbAncestorResolver = {
42
+ resolveAncestors: async () => [],
43
+ };
44
+
45
+ it("builds [Home, self] trail with no ancestors", async () => {
46
+ const trail = await buildBreadcrumbTrail({
47
+ pageId: "about",
48
+ pageTitle: "About | Site",
49
+ selfUrl: "https://example.com/de/about/",
50
+ homeLabel: "Home",
51
+ homeUrl: "https://example.com/de/",
52
+ lang: "de",
53
+ defaultLang: "de",
54
+ resolver: noopResolver,
55
+ });
56
+ expect(trail).toHaveLength(2);
57
+ expect(trail[0]).toEqual({ name: "Home", url: "https://example.com/de/" });
58
+ expect(trail[1]).toEqual({
59
+ name: "About",
60
+ url: "https://example.com/de/about/",
61
+ pageId: "about",
62
+ });
63
+ });
64
+
65
+ it("inserts ancestors between Home and self", async () => {
66
+ const resolver: BreadcrumbAncestorResolver = {
67
+ resolveAncestors: async () => [
68
+ { name: "Section", url: "https://example.com/de/section/" },
69
+ ],
70
+ };
71
+ const trail = await buildBreadcrumbTrail({
72
+ pageId: "page",
73
+ pageTitle: "Page",
74
+ selfUrl: "https://example.com/de/section/page/",
75
+ homeLabel: "Home",
76
+ homeUrl: "https://example.com/de/",
77
+ lang: "de",
78
+ defaultLang: "de",
79
+ resolver,
80
+ });
81
+ expect(trail).toHaveLength(3);
82
+ expect(trail[1]).toEqual({ name: "Section", url: "https://example.com/de/section/" });
83
+ });
84
+
85
+ it("deduplicates ancestors that collapse onto Home or self", async () => {
86
+ const resolver: BreadcrumbAncestorResolver = {
87
+ resolveAncestors: async () => [
88
+ { name: "Home", url: "https://example.com/de/" },
89
+ { name: "Self", url: "https://example.com/de/page/" },
90
+ { name: "Real Ancestor", url: "https://example.com/de/real/" },
91
+ ],
92
+ };
93
+ const trail = await buildBreadcrumbTrail({
94
+ pageId: "page",
95
+ pageTitle: "Page",
96
+ selfUrl: "https://example.com/de/page/",
97
+ homeLabel: "Home",
98
+ homeUrl: "https://example.com/de/",
99
+ lang: "de",
100
+ defaultLang: "de",
101
+ resolver,
102
+ });
103
+ expect(trail).toHaveLength(3);
104
+ expect(trail[1]).toEqual({ name: "Real Ancestor", url: "https://example.com/de/real/" });
105
+ });
106
+
107
+ it("drops ancestors with empty names", async () => {
108
+ const resolver: BreadcrumbAncestorResolver = {
109
+ resolveAncestors: async () => [
110
+ { name: " ", url: "https://example.com/de/empty/" },
111
+ { name: "Valid", url: "https://example.com/de/valid/" },
112
+ ],
113
+ };
114
+ const trail = await buildBreadcrumbTrail({
115
+ pageId: "page",
116
+ pageTitle: "Page",
117
+ selfUrl: "https://example.com/de/page/",
118
+ homeLabel: "Home",
119
+ homeUrl: "https://example.com/de/",
120
+ lang: "de",
121
+ defaultLang: "de",
122
+ resolver,
123
+ });
124
+ expect(trail).toHaveLength(3);
125
+ expect(trail[1]).toEqual({ name: "Valid", url: "https://example.com/de/valid/" });
126
+ });
127
+ });
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildSemanticPageModelWith } from "../build-page.ts";
3
+ import type { SemanticContentReader } from "../build-page.ts";
4
+
5
+ function makeReader(overrides: Partial<SemanticContentReader> = {}): SemanticContentReader {
6
+ return {
7
+ getPageFrontmatter: async () => ({
8
+ title: "Test Page",
9
+ description: "A test page",
10
+ blocks: [],
11
+ }),
12
+ getProseBody: async () => "",
13
+ getHomeLabel: async () => "Home",
14
+ getFaqEntries: async () => [],
15
+ getDerivedPrices: () => null,
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ const baseArgs = {
21
+ pageId: "test-page",
22
+ semanticType: "content" as const,
23
+ lang: "de",
24
+ url: "https://example.com/de/page/",
25
+ profile: {
26
+ organization: { name: "Org", description: "Desc", url: "https://example.com" },
27
+ people: [],
28
+ initiatives: [],
29
+ },
30
+ };
31
+
32
+ describe("buildSemanticPageModelWith", () => {
33
+ it("builds a SemanticPageModel from reader frontmatter", async () => {
34
+ const reader = makeReader();
35
+ const result = await buildSemanticPageModelWith(reader, baseArgs);
36
+ expect(result).not.toBeNull();
37
+ expect(result!.title).toBe("Test Page");
38
+ expect(result!.description).toBe("A test page");
39
+ expect(result!.lang).toBe("de");
40
+ });
41
+
42
+ it("returns null when reader returns null frontmatter and no fallback", async () => {
43
+ const reader = makeReader({ getPageFrontmatter: async () => null });
44
+ const result = await buildSemanticPageModelWith(reader, baseArgs);
45
+ expect(result).toBeNull();
46
+ });
47
+
48
+ it("uses fallbackFrontmatter when reader returns null", async () => {
49
+ const reader = makeReader({ getPageFrontmatter: async () => null });
50
+ const result = await buildSemanticPageModelWith(reader, {
51
+ ...baseArgs,
52
+ fallbackFrontmatter: { title: "Fallback", description: "Fallback desc", blocks: [] },
53
+ });
54
+ expect(result).not.toBeNull();
55
+ expect(result!.title).toBe("Fallback");
56
+ });
57
+
58
+ it("attaches people for about pages", async () => {
59
+ const reader = makeReader();
60
+ const result = await buildSemanticPageModelWith(reader, {
61
+ ...baseArgs,
62
+ semanticType: "about",
63
+ profile: {
64
+ organization: { name: "Org", description: "Desc", url: "https://example.com" },
65
+ people: [{ name: "Jane", role: "CEO" }],
66
+ initiatives: [],
67
+ },
68
+ });
69
+ expect(result!.people).toEqual([{ name: "Jane", role: "CEO" }]);
70
+ });
71
+
72
+ it("attaches initiatives for projects pages", async () => {
73
+ const reader = makeReader();
74
+ const result = await buildSemanticPageModelWith(reader, {
75
+ ...baseArgs,
76
+ semanticType: "projects",
77
+ profile: {
78
+ organization: { name: "Org", description: "Desc", url: "https://example.com" },
79
+ people: [],
80
+ initiatives: [{ id: "i1", name: "Project", summary: "A project" }],
81
+ },
82
+ });
83
+ expect(result!.initiatives).toEqual([{ id: "i1", name: "Project", summary: "A project" }]);
84
+ });
85
+
86
+ it("attaches FAQ entries for donationContact pages", async () => {
87
+ const reader = makeReader({
88
+ getFaqEntries: async () => [{ id: "q1", question: "Q?", answer: "A." }],
89
+ });
90
+ const result = await buildSemanticPageModelWith(reader, {
91
+ ...baseArgs,
92
+ semanticType: "donationContact",
93
+ });
94
+ expect(result!.faqEntries).toEqual([{ id: "q1", question: "Q?", answer: "A." }]);
95
+ });
96
+
97
+ it("uses provided breadcrumbs when supplied", async () => {
98
+ const reader = makeReader();
99
+ const result = await buildSemanticPageModelWith(reader, {
100
+ ...baseArgs,
101
+ breadcrumbs: [
102
+ { name: "Home", url: "https://example.com/de/" },
103
+ { name: "Section", url: "https://example.com/de/section/" },
104
+ { name: "Page", url: "https://example.com/de/page/" },
105
+ ],
106
+ });
107
+ expect(result!.breadcrumbs).toHaveLength(3);
108
+ });
109
+
110
+ it("resolves price markers in heading and description", async () => {
111
+ const reader = makeReader({
112
+ getPageFrontmatter: async () => ({
113
+ title: "Pricing",
114
+ description: "Cost: {amount:50}",
115
+ blocks: [],
116
+ }),
117
+ });
118
+ const result = await buildSemanticPageModelWith(reader, baseArgs);
119
+ expect(result!.description).toContain("€");
120
+ expect(result!.description).not.toContain("{amount:");
121
+ });
122
+ });
@@ -0,0 +1,188 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ BUSINESS_DOMAIN_VISIBILITY,
4
+ projectLocation,
5
+ projectOffer,
6
+ projectPeople,
7
+ projectServices,
8
+ projectWeb,
9
+ } from "../business-projection.ts";
10
+
11
+ describe("BUSINESS_DOMAIN_VISIBILITY", () => {
12
+ it("marks company as public", () => {
13
+ expect(BUSINESS_DOMAIN_VISIBILITY.company).toBe("public");
14
+ });
15
+
16
+ it("marks compliance as none", () => {
17
+ expect(BUSINESS_DOMAIN_VISIBILITY.compliance).toBe("none");
18
+ });
19
+
20
+ it("marks externalServices as none", () => {
21
+ expect(BUSINESS_DOMAIN_VISIBILITY.externalServices).toBe("none");
22
+ });
23
+
24
+ it("marks meta as pageMeta", () => {
25
+ expect(BUSINESS_DOMAIN_VISIBILITY.meta).toBe("pageMeta");
26
+ });
27
+ });
28
+
29
+ describe("projectOffer", () => {
30
+ it("returns undefined for no data", () => {
31
+ expect(projectOffer(undefined)).toBeUndefined();
32
+ });
33
+
34
+ it("projects prices with labels", () => {
35
+ const result = projectOffer({ price: { monthly: "10", yearly: "100" }, currency: "EUR" });
36
+ expect(result?.prices).toHaveLength(2);
37
+ expect(result?.prices![0]).toEqual({
38
+ id: "monthly",
39
+ label: "Monthly",
40
+ amount: "10",
41
+ currency: "EUR",
42
+ });
43
+ });
44
+
45
+ it("projects guarantees", () => {
46
+ const result = projectOffer({
47
+ guarantees: { availability: { label: "99.9% Uptime", detail: "SLA guaranteed" } },
48
+ });
49
+ expect(result?.guarantees).toHaveLength(1);
50
+ expect(result?.guarantees![0]).toEqual({
51
+ id: "availability",
52
+ label: "99.9% Uptime",
53
+ detail: "SLA guaranteed",
54
+ });
55
+ });
56
+
57
+ it("projects growth modules", () => {
58
+ const result = projectOffer({
59
+ growthModules: { pro: { label: "Pro", description: "Advanced features", price: "50" } },
60
+ });
61
+ expect(result?.growthModules).toHaveLength(1);
62
+ expect(result?.growthModules![0]).toEqual({
63
+ id: "pro",
64
+ label: "Pro",
65
+ description: "Advanced features",
66
+ price: "50",
67
+ });
68
+ });
69
+
70
+ it("projects terms (changePrice, hourlyRate, billingDay)", () => {
71
+ const result = projectOffer({ changePrice: "5", hourlyRate: "80", billingDay: "15" });
72
+ expect(result?.changePrice).toBe("5");
73
+ expect(result?.hourlyRate).toBe("80");
74
+ expect(result?.billingDay).toBe("15");
75
+ });
76
+ });
77
+
78
+ describe("projectPeople", () => {
79
+ it("returns empty array for no records", () => {
80
+ expect(projectPeople(undefined)).toEqual([]);
81
+ });
82
+
83
+ it("projects people with name and role", () => {
84
+ const result = projectPeople([{ name: "Jane", role: "CEO", bio: "Leader" }]);
85
+ expect(result).toHaveLength(1);
86
+ expect(result[0].name).toBe("Jane");
87
+ expect(result[0].role).toBe("CEO");
88
+ expect(result[0].description).toBe("Leader");
89
+ });
90
+
91
+ it("skips records without name", () => {
92
+ const result = projectPeople([{ role: "CEO" }, { name: "Jane" }]);
93
+ expect(result).toHaveLength(1);
94
+ expect(result[0].name).toBe("Jane");
95
+ });
96
+
97
+ it("maps sameAs and affiliations", () => {
98
+ const result = projectPeople([
99
+ {
100
+ name: "Jane",
101
+ sameAs: ["https://twitter.com/jane"],
102
+ affiliations: ["Org A"],
103
+ },
104
+ ]);
105
+ expect(result[0].sameAs).toEqual(["https://twitter.com/jane"]);
106
+ expect(result[0].affiliations).toEqual(["Org A"]);
107
+ });
108
+
109
+ it("maps birth/death dates", () => {
110
+ const result = projectPeople([{ name: "Jane", lifespan: { born: "1980", died: "2020" } }]);
111
+ expect(result[0].birthDate).toBe("1980");
112
+ expect(result[0].deathDate).toBe("2020");
113
+ expect(result[0].isDeceased).toBe(true);
114
+ });
115
+ });
116
+
117
+ describe("projectLocation", () => {
118
+ it("returns undefined for no data", () => {
119
+ expect(projectLocation(undefined)).toBeUndefined();
120
+ });
121
+
122
+ it("projects city, region, country", () => {
123
+ const result = projectLocation({
124
+ city: { name: "Berlin" },
125
+ region: { name: "BE" },
126
+ country: { name: "DE" },
127
+ });
128
+ expect(result?.locality).toBe("Berlin");
129
+ expect(result?.region).toBe("BE");
130
+ expect(result?.country).toBe("DE");
131
+ });
132
+
133
+ it("projects serviceArea", () => {
134
+ const result = projectLocation({ serviceArea: ["Berlin", "Potsdam"] });
135
+ expect(result?.serviceArea).toEqual(["Berlin", "Potsdam"]);
136
+ });
137
+
138
+ it("returns undefined when all fields empty", () => {
139
+ expect(projectLocation({})).toBeUndefined();
140
+ });
141
+ });
142
+
143
+ describe("projectServices", () => {
144
+ it("returns empty for no records", () => {
145
+ expect(projectServices(undefined)).toEqual([]);
146
+ });
147
+
148
+ it("projects services with name and description", () => {
149
+ const result = projectServices([
150
+ { name: "Hosting", slug: "hosting", description: "Web hosting" },
151
+ ]);
152
+ expect(result).toHaveLength(1);
153
+ expect(result[0]).toEqual({ id: "hosting", name: "Hosting", description: "Web hosting" });
154
+ });
155
+
156
+ it("uses name as id when slug missing", () => {
157
+ const result = projectServices([{ name: "Consulting" }]);
158
+ expect(result[0].id).toBe("Consulting");
159
+ });
160
+
161
+ it("drops records without name", () => {
162
+ expect(projectServices([{ description: "No name" }])).toEqual([]);
163
+ });
164
+ });
165
+
166
+ describe("projectWeb", () => {
167
+ it("returns undefined for no data", () => {
168
+ expect(projectWeb(undefined)).toBeUndefined();
169
+ });
170
+
171
+ it("projects primaryUrl and statusUrl", () => {
172
+ const result = projectWeb({
173
+ primaryUrl: "https://example.com",
174
+ statusUrl: "https://status.example.com",
175
+ });
176
+ expect(result?.primaryUrl).toBe("https://example.com");
177
+ expect(result?.statusUrl).toBe("https://status.example.com");
178
+ });
179
+
180
+ it("projects domains", () => {
181
+ const result = projectWeb({ domains: { primary: "example.com", german: "example.de" } });
182
+ expect(result?.domains).toEqual({ primary: "example.com", german: "example.de" });
183
+ });
184
+
185
+ it("returns undefined when all fields empty", () => {
186
+ expect(projectWeb({})).toBeUndefined();
187
+ });
188
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { loadDerivedPrices } from "../derived-prices-loader.ts";
6
+
7
+ describe("loadDerivedPrices", () => {
8
+ it("returns null when file does not exist", () => {
9
+ const tmpDir = mkdtempSync(join(tmpdir(), "test-prices-"));
10
+ try {
11
+ const result = loadDerivedPrices(tmpDir);
12
+ expect(result).toBeNull();
13
+ } finally {
14
+ rmSync(tmpDir, { recursive: true });
15
+ }
16
+ });
17
+
18
+ it("loads and parses JSON when file exists", () => {
19
+ const tmpDir = mkdtempSync(join(tmpdir(), "test-prices-"));
20
+ try {
21
+ const srcDir = join(tmpDir, "src");
22
+ mkdirSync(srcDir, { recursive: true });
23
+ writeFileSync(
24
+ join(srcDir, "derived-prices.generated.json"),
25
+ JSON.stringify({
26
+ "https://warpgogol.com/id/offerings/hosting": [
27
+ {
28
+ chargeRef: "monthly",
29
+ targetCurrency: "USD",
30
+ amount: { value: "15", currency: "USD" },
31
+ trace: {
32
+ source: { amount: "10", currency: "EUR" },
33
+ rate: { value: "0.9", pair: "EUR/USD" },
34
+ },
35
+ },
36
+ ],
37
+ }),
38
+ );
39
+ const result = loadDerivedPrices(tmpDir);
40
+ expect(result).not.toBeNull();
41
+ expect(Object.keys(result!)).toContain("https://warpgogol.com/id/offerings/hosting");
42
+ } finally {
43
+ rmSync(tmpDir, { recursive: true });
44
+ }
45
+ });
46
+ });
@@ -0,0 +1,126 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ extractEmail,
4
+ extractListFacts,
5
+ extractParagraphs,
6
+ extractPostalAddress,
7
+ mergePeople,
8
+ normalizeWhitespace,
9
+ splitMarkdownSections,
10
+ splitSentences,
11
+ } from "../extract.ts";
12
+
13
+ describe("normalizeWhitespace", () => {
14
+ it("collapses multiple spaces", () => {
15
+ expect(normalizeWhitespace("a b\n c")).toBe("a b c");
16
+ });
17
+ });
18
+
19
+ describe("splitMarkdownSections", () => {
20
+ it("splits by h2 headings", () => {
21
+ const md = "## Section 1\n\nBody 1\n\n## Section 2\n\nBody 2";
22
+ const sections = splitMarkdownSections(md);
23
+ expect(sections).toHaveLength(2);
24
+ expect(sections[0]).toEqual({ heading: "Section 1", body: "Body 1" });
25
+ expect(sections[1]).toEqual({ heading: "Section 2", body: "Body 2" });
26
+ });
27
+
28
+ it("splits by h3 when level=3", () => {
29
+ const md = "### Sub 1\n\nBody 1\n\n### Sub 2\n\nBody 2";
30
+ const sections = splitMarkdownSections(md, 3);
31
+ expect(sections).toHaveLength(2);
32
+ expect(sections[0].heading).toBe("Sub 1");
33
+ });
34
+
35
+ it("returns empty for empty input", () => {
36
+ expect(splitMarkdownSections("")).toEqual([]);
37
+ });
38
+ });
39
+
40
+ describe("extractParagraphs", () => {
41
+ it("splits on blank lines", () => {
42
+ expect(extractParagraphs("Para 1\n\nPara 2\n\nPara 3")).toEqual(["Para 1", "Para 2", "Para 3"]);
43
+ });
44
+ });
45
+
46
+ describe("splitSentences", () => {
47
+ it("splits on period + space + capital", () => {
48
+ expect(splitSentences("Hello world. Next sentence.")).toEqual([
49
+ "Hello world.",
50
+ "Next sentence.",
51
+ ]);
52
+ });
53
+
54
+ it("handles German abbreviations", () => {
55
+ const result = splitSentences("Das ist z.B. ein Test. Und noch einer.", "de");
56
+ expect(result).toHaveLength(2);
57
+ expect(result[0]).toContain("z.B.");
58
+ });
59
+
60
+ it("handles English abbreviations", () => {
61
+ const result = splitSentences("See e.g. the docs. For more info.", "en");
62
+ expect(result).toHaveLength(2);
63
+ });
64
+
65
+ it("skips numbered list markers", () => {
66
+ const result = splitSentences("1. First item 2. Second item", "en");
67
+ expect(result).toHaveLength(1);
68
+ });
69
+
70
+ it("returns empty for empty input", () => {
71
+ expect(splitSentences("")).toEqual([]);
72
+ });
73
+ });
74
+
75
+ describe("extractListFacts", () => {
76
+ it("extracts bullet list items", () => {
77
+ expect(extractListFacts("- Item 1\n- Item 2")).toEqual(["Item 1", "Item 2"]);
78
+ });
79
+
80
+ it("extracts numbered list items", () => {
81
+ expect(extractListFacts("1. First\n2. Second")).toEqual(["First", "Second"]);
82
+ });
83
+ });
84
+
85
+ describe("extractEmail", () => {
86
+ it("extracts email from text", () => {
87
+ expect(extractEmail("Contact us at info@example.com today")).toBe("info@example.com");
88
+ });
89
+
90
+ it("returns undefined when no email", () => {
91
+ expect(extractEmail("No email here")).toBeUndefined();
92
+ });
93
+ });
94
+
95
+ describe("extractPostalAddress", () => {
96
+ it("extracts address with ZIP", () => {
97
+ const result = extractPostalAddress("Test Str. 1, 12345 Berlin");
98
+ expect(result).toEqual({
99
+ streetAddress: "Test Str. 1",
100
+ postalCode: "12345",
101
+ addressLocality: "Berlin",
102
+ addressCountry: "DE",
103
+ });
104
+ });
105
+
106
+ it("returns undefined when no match", () => {
107
+ expect(extractPostalAddress("No address here")).toBeUndefined();
108
+ });
109
+ });
110
+
111
+ describe("mergePeople", () => {
112
+ it("merges and deduplicates by name", () => {
113
+ const result = mergePeople(
114
+ [{ name: "Jane", role: "Founder" }],
115
+ [{ name: "Jane", description: "Bio" }, { name: "John" }],
116
+ );
117
+ expect(result).toHaveLength(2);
118
+ const jane = result.find((p) => p.name === "Jane");
119
+ expect(jane?.role).toBe("Founder");
120
+ expect(jane?.description).toBe("Bio");
121
+ });
122
+
123
+ it("handles undefined groups", () => {
124
+ expect(mergePeople(undefined, [{ name: "A" }])).toEqual([{ name: "A" }]);
125
+ });
126
+ });