@warpgogol/werkstatt-shared 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +19 -4
  2. package/src/checks/index.ts +1 -0
  3. package/src/checks/result-helpers.ts +60 -6
  4. package/src/checks/suppressions-config.ts +26 -1
  5. package/src/content/system-manifest.ts +8 -0
  6. package/src/ontology/archetypes/index.json +17 -1
  7. package/src/ontology/schemas/page-entry.ts +5 -7
  8. package/src/ontology/schemas/system/manifest.ts +8 -0
  9. package/src/ontology/schemas/system/verification.ts +40 -0
  10. package/src/passport/sign.ts +20 -14
  11. package/src/share/middleware/access-protection.ts +174 -0
  12. package/src/share/middleware/tests/access-protection.test.ts +123 -0
  13. package/src/share/page.ts +4 -2
  14. package/src/share/routes/template-filter.ts +36 -0
  15. package/src/share/routes/tests/template-filter.test.ts +40 -0
  16. package/src/share/scripts/lenis.ts +1 -0
  17. package/src/share/semantic/block-extractors/index.ts +24 -0
  18. package/src/share/semantic/build-page.ts +39 -1
  19. package/src/share/semantic/extract.ts +85 -11
  20. package/src/share/semantic/ids.ts +13 -0
  21. package/src/share/semantic/jsonld/organization.ts +20 -0
  22. package/src/share/semantic/jsonld/video.ts +56 -0
  23. package/src/share/semantic/jsonld/webpage.ts +1 -1
  24. package/src/share/semantic/jsonld.ts +5 -0
  25. package/src/share/semantic/models.ts +9 -0
  26. package/src/share/semantic/organization-profile.ts +3 -2
  27. package/src/share/semantic/page-utils.ts +4 -10
  28. package/src/share/semantic/tests/split-sentences.test.ts +97 -0
  29. package/src/share/slug/heading-slugger.ts +26 -0
  30. package/src/share/slug/index.ts +15 -0
  31. package/src/share/slug/slug-id.ts +22 -0
  32. package/src/share/slug/slug-url.ts +24 -0
  33. package/src/share/slug/strategies.ts +68 -0
  34. package/src/share/slug/tests/slug.test.ts +84 -0
  35. package/src/share/tests/build-page-price-markers.test.ts +3 -0
  36. package/src/share/tests/jsonld-video.test.ts +143 -0
  37. package/src/share/tests/jsonld-webpage.test.ts +69 -0
  38. package/src/share/tests/organization-jsonld.test.ts +52 -0
  39. package/src/share/types/axiom-study.d.ts +10 -2
  40. package/src/share/utility-registry.yaml +36 -0
  41. package/src/surface/blueprint-schema.ts +1 -1
  42. package/src/surface/blueprint-types.ts +1 -1
  43. package/src/share/tests/create-dev-props-validator.test.ts +0 -38
@@ -11,18 +11,12 @@
11
11
  <item>Unified single blocksToMarkdown signature (was duplicated with different signatures in app).</item>
12
12
  <item>Added slugify export and markdown answer-block extraction utilities.</item>
13
13
  <item>RFC-0372: toSemanticAnswerBlocks now returns SemanticBlock[] with blockType: "prose".</item>
14
+ <item>RFC-0915: replaced slugify import from extract.ts with slugId from canonical slug module.</item>
14
15
  </CHANGE_SUMMARY>
15
16
  */
16
17
 
17
18
  import type { SemanticBlock } from "./models.ts";
18
- import { slugify } from "./extract.ts";
19
-
20
- export { slugify };
21
-
22
- /**
23
- * Creates a URL-friendly slug from a string.
24
- * Re-exports from extract.ts to keep page-utils self-contained for consumers.
25
- */
19
+ import { slugId } from "../slug/index.ts";
26
20
 
27
21
  /**
28
22
  * Extracts structured answer blocks from markdown body text.
@@ -99,7 +93,7 @@ export function toSemanticAnswerBlocks(
99
93
  const hasMultipleParagraphs = /\n[ \t]*\n/.test(block.content.trim());
100
94
  if (hasTable || hasMultipleParagraphs) {
101
95
  return {
102
- id: slugify(block.heading),
96
+ id: slugId(block.heading),
103
97
  blockType: "prose",
104
98
  heading: block.heading,
105
99
  summary: block.content.trim(),
@@ -115,7 +109,7 @@ export function toSemanticAnswerBlocks(
115
109
  !firstLine.startsWith("-") && !firstLine.startsWith("*") && !firstLine.startsWith("#");
116
110
 
117
111
  return {
118
- id: slugify(block.heading),
112
+ id: slugId(block.heading),
119
113
  blockType: "prose",
120
114
  heading: block.heading,
121
115
  summary: isSummary ? firstLine : undefined,
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { splitSentences } from "@warpgogol/werkstatt-shared/share/semantic";
3
+
4
+ describe("splitSentences", () => {
5
+ it("splits simple English sentences", () => {
6
+ const result = splitSentences("Hello world. This is a test. Goodbye!", "en");
7
+ expect(result).toHaveLength(3);
8
+ expect(result[0]).toBe("Hello world.");
9
+ expect(result[1]).toBe("This is a test.");
10
+ expect(result[2]).toBe("Goodbye!");
11
+ });
12
+
13
+ it("handles German abbreviations", () => {
14
+ const result = splitSentences("Das ist z.B. ein Test. Das ist ein weiterer Satz.", "de");
15
+ expect(result).toHaveLength(2);
16
+ expect(result[0]).toBe("Das ist z.B. ein Test.");
17
+ expect(result[1]).toBe("Das ist ein weiterer Satz.");
18
+ });
19
+
20
+ it("handles English abbreviations", () => {
21
+ const result = splitSentences("Use e.g. this pattern. It works well.", "en");
22
+ expect(result).toHaveLength(2);
23
+ expect(result[0]).toBe("Use e.g. this pattern.");
24
+ expect(result[1]).toBe("It works well.");
25
+ });
26
+
27
+ it("handles Ukrainian text", () => {
28
+ const result = splitSentences("Це перше речення. Це друге речення.", "uk");
29
+ expect(result).toHaveLength(2);
30
+ });
31
+
32
+ it("returns empty array for empty input", () => {
33
+ expect(splitSentences("", "en")).toEqual([]);
34
+ expect(splitSentences(" ", "en")).toEqual([]);
35
+ });
36
+
37
+ it("handles single sentence without terminal punctuation", () => {
38
+ const result = splitSentences("Just some text without ending", "en");
39
+ expect(result).toHaveLength(1);
40
+ expect(result[0]).toBe("Just some text without ending");
41
+ });
42
+
43
+ it("does not split on decimal numbers", () => {
44
+ const result = splitSentences("The price is 3.50 euros. That is cheap.", "en");
45
+ expect(result).toHaveLength(2);
46
+ expect(result[0]).toBe("The price is 3.50 euros.");
47
+ expect(result[1]).toBe("That is cheap.");
48
+ });
49
+
50
+ it("does not split on URLs in Ukrainian text", () => {
51
+ const result = splitSentences(
52
+ "Дивіться https://my.raceresult.com/317721/results для деталей. Це друге речення.",
53
+ "uk",
54
+ );
55
+ expect(result).toHaveLength(2);
56
+ expect(result[0]).toBe("Дивіться https://my.raceresult.com/317721/results для деталей.");
57
+ expect(result[1]).toBe("Це друге речення.");
58
+ });
59
+
60
+ it("does not split on URLs in German text", () => {
61
+ const result = splitSentences(
62
+ "Siehe https://my.raceresult.com/317721/results für Details. Das ist ein zweiter Satz.",
63
+ "de",
64
+ );
65
+ expect(result).toHaveLength(2);
66
+ expect(result[0]).toBe("Siehe https://my.raceresult.com/317721/results für Details.");
67
+ expect(result[1]).toBe("Das ist ein zweiter Satz.");
68
+ });
69
+
70
+ it("splits at period before German umlaut", () => {
71
+ const result = splitSentences(
72
+ "Die Version wird festgehalten. Änderungen erfolgen gemäß § 15.",
73
+ "de",
74
+ );
75
+ expect(result).toHaveLength(2);
76
+ expect(result[0]).toBe("Die Version wird festgehalten.");
77
+ expect(result[1]).toBe("Änderungen erfolgen gemäß § 15.");
78
+ });
79
+
80
+ it("does not split at numbered list markers", () => {
81
+ const result = splitSentences(
82
+ "1. Vorlage eines konkreten Angebots durch das Studio; 2. ausdrücklicher Annahme des Angebots durch den Kunden; 3. Auftragsbestätigung durch das Studio zustande.",
83
+ "de",
84
+ );
85
+ expect(result).toHaveLength(1);
86
+ });
87
+
88
+ it("handles spaced German abbreviation z. B.", () => {
89
+ const result = splitSentences(
90
+ "Das Studio kann nicht wesentliche Prozesse (z. B. Werkzeuge) ändern. Der wesentliche Leistungsumfang kann nicht einseitig geändert werden.",
91
+ "de",
92
+ );
93
+ expect(result).toHaveLength(2);
94
+ expect(result[0]).toBe("Das Studio kann nicht wesentliche Prozesse (z. B. Werkzeuge) ändern.");
95
+ expect(result[1]).toBe("Der wesentliche Leistungsumfang kann nicht einseitig geändert werden.");
96
+ });
97
+ });
@@ -0,0 +1,26 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Canonical heading anchor slug generation with stateful deduplication (RFC-0915, DNA-88). Wraps github-slugger.</purpose>
4
+ <non-goals>
5
+ <item>Do not handle URL slug generation — use slugUrl for that.</item>
6
+ </non-goals>
7
+ </MODULE_CONTRACT>
8
+ <CHANGE_SUMMARY>
9
+ <item>RFC-0915: wraps github-slugger as canonical heading slugger, replacing direct imports in werkstatt-site.</item>
10
+ </CHANGE_SUMMARY>
11
+ */
12
+
13
+ import GithubSlugger from "github-slugger";
14
+
15
+ /**
16
+ * Stateful heading slug generator with deduplication.
17
+ * First "Fazit" → "fazit", second → "fazit-1".
18
+ * Wraps github-slugger for canonical heading anchor generation.
19
+ */
20
+ export class HeadingSlugger {
21
+ private readonly slugger = new GithubSlugger();
22
+
23
+ slug(text: string): string {
24
+ return this.slugger.slug(text);
25
+ }
26
+ }
@@ -0,0 +1,15 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Canonical slug generation public API barrel (RFC-0915, DNA-88). Sole entry point for all slug generation in the monorepo.</purpose>
4
+ <non-goals>
5
+ <item>Do not re-export strategy classes — consumers use slugUrl/slugId/HeadingSlugger only.</item>
6
+ </non-goals>
7
+ </MODULE_CONTRACT>
8
+ <CHANGE_SUMMARY>
9
+ <item>RFC-0915: created canonical slug module barrel.</item>
10
+ </CHANGE_SUMMARY>
11
+ */
12
+
13
+ export { slugUrl } from "./slug-url.ts";
14
+ export { slugId } from "./slug-id.ts";
15
+ export { HeadingSlugger } from "./heading-slugger.ts";
@@ -0,0 +1,22 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Canonical semantic block ID slug generation (RFC-0915, DNA-88). Replaces custom NFKD slugify() in extract.ts.</purpose>
4
+ <non-goals>
5
+ <item>Do not handle locale-aware URL slugs — use slugUrl for that.</item>
6
+ </non-goals>
7
+ </MODULE_CONTRACT>
8
+ <CHANGE_SUMMARY>
9
+ <item>RFC-0915: replaces custom NFKD-based slugify() in semantic/extract.ts with @sindresorhus/slugify wrapper.</item>
10
+ </CHANGE_SUMMARY>
11
+ */
12
+
13
+ import slugify from "@sindresorhus/slugify";
14
+
15
+ /**
16
+ * Generates a semantic block ID from text.
17
+ * Uses @sindresorhus/slugify for robust Unicode handling.
18
+ * Returns "entity" if the input produces an empty slug.
19
+ */
20
+ export function slugId(text: string): string {
21
+ return slugify(text) || "entity";
22
+ }
@@ -0,0 +1,24 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Canonical locale-aware URL slug generation (RFC-0915, DNA-88).</purpose>
4
+ <non-goals>
5
+ <item>Do not handle heading anchor deduplication — use HeadingSlugger for that.</item>
6
+ </non-goals>
7
+ </MODULE_CONTRACT>
8
+ <CHANGE_SUMMARY>
9
+ <item>RFC-0915: extracted from werkstatt-site/src/domain/geo/slug.ts as canonical URL slug function.</item>
10
+ </CHANGE_SUMMARY>
11
+ */
12
+
13
+ import { resolveSlugStrategy } from "./strategies.ts";
14
+
15
+ /**
16
+ * Generates a locale-aware Latin URL slug from text.
17
+ * Uses German umlaut replacements for lang="de",
18
+ * Cyrillic transliteration for lang="uk",
19
+ * and default @sindresorhus/slugify for other/undefined langs.
20
+ * Returns "entity" if the input produces an empty slug.
21
+ */
22
+ export function slugUrl(text: string, lang?: string): string {
23
+ return resolveSlugStrategy(lang).slug(text) || "entity";
24
+ }
@@ -0,0 +1,68 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Canonical slug generation strategies for locale-aware URL slug derivation (RFC-0915, DNA-88).</purpose>
4
+ <non-goals>
5
+ <item>Do not expose strategy classes directly — consumers use slugUrl() from slug-url.ts.</item>
6
+ </non-goals>
7
+ </MODULE_CONTRACT>
8
+ <CHANGE_SUMMARY>
9
+ <item>RFC-0915: extracted from werkstatt-site/src/domain/geo/slug.ts as canonical slug strategies.</item>
10
+ </CHANGE_SUMMARY>
11
+ */
12
+
13
+ import slugify from "@sindresorhus/slugify";
14
+ import CyrillicToTranslit from "cyrillic-to-translit-js";
15
+
16
+ export interface SlugStrategy {
17
+ slug(name: string): string;
18
+ }
19
+
20
+ interface CyrillicTranslit {
21
+ transform(value: string): string;
22
+ }
23
+
24
+ interface CyrillicTranslitConstructor {
25
+ new (options: { preset: "uk" }): CyrillicTranslit;
26
+ }
27
+
28
+ const germanReplacements: Array<[string, string]> = [
29
+ ["ä", "ae"],
30
+ ["ö", "oe"],
31
+ ["ü", "ue"],
32
+ ["ß", "ss"],
33
+ ["Ä", "Ae"],
34
+ ["Ö", "Oe"],
35
+ ["Ü", "Ue"],
36
+ ];
37
+
38
+ class GermanSlugStrategy implements SlugStrategy {
39
+ slug(name: string): string {
40
+ return slugify(name, { customReplacements: germanReplacements });
41
+ }
42
+ }
43
+
44
+ class UkrainianSlugStrategy implements SlugStrategy {
45
+ private readonly translit = new (CyrillicToTranslit as unknown as CyrillicTranslitConstructor)({
46
+ preset: "uk",
47
+ });
48
+ slug(name: string): string {
49
+ return slugify(this.translit.transform(name));
50
+ }
51
+ }
52
+
53
+ class DefaultSlugStrategy implements SlugStrategy {
54
+ slug(name: string): string {
55
+ return slugify(name);
56
+ }
57
+ }
58
+
59
+ const slugStrategies = new Map<string, SlugStrategy>([
60
+ ["de", new GermanSlugStrategy()],
61
+ ["uk", new UkrainianSlugStrategy()],
62
+ ]);
63
+
64
+ const defaultStrategy = new DefaultSlugStrategy();
65
+
66
+ export function resolveSlugStrategy(lang?: string): SlugStrategy {
67
+ return (lang ? slugStrategies.get(lang) : undefined) ?? defaultStrategy;
68
+ }
@@ -0,0 +1,84 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>RFC-0915: unit tests for canonical slug module output compatibility.</purpose>
4
+ <keywords>RFC-0915, slug, slugUrl, slugId, HeadingSlugger, DNA-88</keywords>
5
+ <responsibilities>
6
+ <item>Verify slugUrl locale-aware output for DE, UK, and default locales.</item>
7
+ <item>Verify slugId semantic block ID output and empty fallback.</item>
8
+ <item>Verify HeadingSlugger deduplication behavior.</item>
9
+ </responsibilities>
10
+ </MODULE_CONTRACT>
11
+ <CHANGE_SUMMARY><item>RFC-0915: initial unit tests for canonical slug module.</item></CHANGE_SUMMARY>
12
+ */
13
+
14
+ import { test, expect, describe } from "vitest";
15
+ import { slugUrl, slugId, HeadingSlugger } from "../index.ts";
16
+
17
+ describe("slugUrl", () => {
18
+ test("German umlauts are expanded", () => {
19
+ expect(slugUrl("München", "de")).toBe("muenchen");
20
+ expect(slugUrl("Köln", "de")).toBe("koeln");
21
+ expect(slugUrl("Düsseldorf", "de")).toBe("duesseldorf");
22
+ expect(slugUrl("ß", "de")).toBe("ss");
23
+ });
24
+
25
+ test("Ukrainian Cyrillic is transliterated", () => {
26
+ expect(slugUrl("Київ", "uk")).toBe("kyiv");
27
+ expect(slugUrl("Львів", "uk")).toBe("lviv");
28
+ });
29
+
30
+ test("Default locale passes through", () => {
31
+ expect(slugUrl("Hello World")).toBe("hello-world");
32
+ expect(slugUrl("Berlin", "en")).toBe("berlin");
33
+ expect(slugUrl("New York", "en")).toBe("new-york");
34
+ });
35
+
36
+ test("Returns entity for empty input", () => {
37
+ expect(slugUrl("")).toBe("entity");
38
+ expect(slugUrl("!!!", "de")).toBe("entity");
39
+ });
40
+
41
+ test("Is idempotent", () => {
42
+ const once = slugUrl("Frankfurt am Main", "de");
43
+ expect(slugUrl(once, "de")).toBe(once);
44
+ });
45
+ });
46
+
47
+ describe("slugId", () => {
48
+ test("Generates kebab-case ID from heading", () => {
49
+ expect(slugId("Fazit")).toBe("fazit");
50
+ expect(slugId("Preisvergleich")).toBe("preisvergleich");
51
+ expect(slugId("FAQ & Antworten")).toBe("faq-and-antworten");
52
+ });
53
+
54
+ test("Returns entity for empty input", () => {
55
+ expect(slugId("")).toBe("entity");
56
+ expect(slugId("!!!")).toBe("entity");
57
+ });
58
+ });
59
+
60
+ describe("HeadingSlugger", () => {
61
+ test("First occurrence has no suffix", () => {
62
+ const slugger = new HeadingSlugger();
63
+ expect(slugger.slug("Fazit")).toBe("fazit");
64
+ });
65
+
66
+ test("Second occurrence gets -1 suffix", () => {
67
+ const slugger = new HeadingSlugger();
68
+ slugger.slug("Fazit");
69
+ expect(slugger.slug("Fazit")).toBe("fazit-1");
70
+ });
71
+
72
+ test("Third occurrence gets -2 suffix", () => {
73
+ const slugger = new HeadingSlugger();
74
+ slugger.slug("Fazit");
75
+ slugger.slug("Fazit");
76
+ expect(slugger.slug("Fazit")).toBe("fazit-2");
77
+ });
78
+
79
+ test("Different headings do not collide", () => {
80
+ const slugger = new HeadingSlugger();
81
+ expect(slugger.slug("Preis")).toBe("preis");
82
+ expect(slugger.slug("Fazit")).toBe("fazit");
83
+ });
84
+ });
@@ -69,6 +69,7 @@ describe("buildSemanticPageModelWith with price markers", () => {
69
69
  description: "Test description",
70
70
  blocks: [
71
71
  {
72
+ id: "hero",
72
73
  type: "hero",
73
74
  props: {
74
75
  header: {
@@ -102,6 +103,7 @@ describe("buildSemanticPageModelWith with price markers", () => {
102
103
  description: "Test description",
103
104
  blocks: [
104
105
  {
106
+ id: "hero",
105
107
  type: "hero",
106
108
  props: {
107
109
  header: {
@@ -134,6 +136,7 @@ describe("buildSemanticPageModelWith with price markers", () => {
134
136
  description: "A page without price markers",
135
137
  blocks: [
136
138
  {
139
+ id: "hero",
137
140
  type: "hero",
138
141
  props: {
139
142
  header: {
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildVideoObjectNodes } from "../semantic/jsonld/video.ts";
3
+ import { createJsonLdContext } from "../semantic/jsonld/context.ts";
4
+ import type { SemanticPageModel, VideoSeoData } from "../semantic/models.ts";
5
+
6
+ /*
7
+ <MODULE_CONTRACT>
8
+ <purpose>
9
+ RFC-0912: Tests for buildVideoObjectNodes — the JSON-LD builder that emits
10
+ VideoObject nodes from SemanticBlock.video data on opted-in content video blocks.
11
+ </purpose>
12
+ </MODULE_CONTRACT>
13
+ */
14
+
15
+ function makeMinimalPage(overrides: Partial<SemanticPageModel> = {}): SemanticPageModel {
16
+ return {
17
+ url: "https://example.com/uk/demo",
18
+ type: "article",
19
+ lang: "uk",
20
+ title: "Demo Page",
21
+ description: "A demo page",
22
+ blocks: [],
23
+ organization: { name: "Test Org", description: "Test", url: "https://example.com" },
24
+ ...overrides,
25
+ } as unknown as SemanticPageModel;
26
+ }
27
+
28
+ const sampleVideoSeo: VideoSeoData = {
29
+ seo: {
30
+ name: "Demo Video",
31
+ description: "A demonstration of the platform",
32
+ uploadDate: "2026-01-15T00:00:00Z",
33
+ },
34
+ manifest: {
35
+ posterUrl: "https://example.com/_video/uk/demo/poster.webp",
36
+ contentUrl: "https://example.com/_video/uk/demo/progressive.h264.mp4",
37
+ durationSec: 120,
38
+ },
39
+ };
40
+
41
+ describe("buildVideoObjectNodes", () => {
42
+ it("returns empty array when no blocks have video data", () => {
43
+ const page = makeMinimalPage({
44
+ blocks: [{ id: "block-1", heading: "Intro" }],
45
+ });
46
+ const ctx = createJsonLdContext(page);
47
+ const nodes = buildVideoObjectNodes(ctx);
48
+ expect(nodes).toHaveLength(0);
49
+ });
50
+
51
+ it("emits a VideoObject node for a block with video data", () => {
52
+ const page = makeMinimalPage({
53
+ blocks: [{ id: "video-section", heading: "Demo", video: sampleVideoSeo }],
54
+ });
55
+ const ctx = createJsonLdContext(page);
56
+ const nodes = buildVideoObjectNodes(ctx);
57
+
58
+ expect(nodes).toHaveLength(1);
59
+ const node = nodes[0]!;
60
+ expect(node["@type"]).toBe("VideoObject");
61
+ expect(node.name).toBe("Demo Video");
62
+ expect(node.description).toBe("A demonstration of the platform");
63
+ expect(node.uploadDate).toBe("2026-01-15T00:00:00Z");
64
+ expect(node.thumbnailUrl).toBe("https://example.com/_video/uk/demo/poster.webp");
65
+ expect(node.contentUrl).toBe("https://example.com/_video/uk/demo/progressive.h264.mp4");
66
+ });
67
+
68
+ it("includes ISO 8601 duration when durationSec is present", () => {
69
+ const page = makeMinimalPage({
70
+ blocks: [{ id: "v1", heading: "Demo", video: sampleVideoSeo }],
71
+ });
72
+ const ctx = createJsonLdContext(page);
73
+ const nodes = buildVideoObjectNodes(ctx);
74
+
75
+ expect(nodes[0]!.duration).toBe("PT2M0S");
76
+ });
77
+
78
+ it("omits duration when durationSec is absent", () => {
79
+ const videoWithoutDuration: VideoSeoData = {
80
+ seo: sampleVideoSeo.seo,
81
+ manifest: {
82
+ posterUrl: sampleVideoSeo.manifest.posterUrl,
83
+ contentUrl: sampleVideoSeo.manifest.contentUrl,
84
+ },
85
+ };
86
+ const page = makeMinimalPage({
87
+ blocks: [{ id: "v1", heading: "Demo", video: videoWithoutDuration }],
88
+ });
89
+ const ctx = createJsonLdContext(page);
90
+ const nodes = buildVideoObjectNodes(ctx);
91
+
92
+ expect(nodes[0]!.duration).toBeUndefined();
93
+ });
94
+
95
+ it("emits multiple nodes for multiple video blocks", () => {
96
+ const page = makeMinimalPage({
97
+ blocks: [
98
+ { id: "v1", heading: "Demo 1", video: sampleVideoSeo },
99
+ {
100
+ id: "v2",
101
+ heading: "Demo 2",
102
+ video: {
103
+ seo: { name: "Second", description: "Second video", uploadDate: "2026-02-01" },
104
+ manifest: {
105
+ posterUrl: "https://example.com/_video/uk/demo2/poster.webp",
106
+ contentUrl: "https://example.com/_video/uk/demo2/progressive.h264.mp4",
107
+ durationSec: 3661,
108
+ },
109
+ },
110
+ },
111
+ ],
112
+ });
113
+ const ctx = createJsonLdContext(page);
114
+ const nodes = buildVideoObjectNodes(ctx);
115
+
116
+ expect(nodes).toHaveLength(2);
117
+ expect(nodes[0]!.name).toBe("Demo Video");
118
+ expect(nodes[1]!.name).toBe("Second");
119
+ expect(nodes[1]!.duration).toBe("PT1H1M1S");
120
+ });
121
+
122
+ it("generates unique @id per video block", () => {
123
+ const page = makeMinimalPage({
124
+ blocks: [
125
+ { id: "v1", heading: "Demo 1", video: sampleVideoSeo },
126
+ {
127
+ id: "v2",
128
+ heading: "Demo 2",
129
+ video: {
130
+ ...sampleVideoSeo,
131
+ seo: { name: "Second", description: "d", uploadDate: "2026-02-01" },
132
+ },
133
+ },
134
+ ],
135
+ });
136
+ const ctx = createJsonLdContext(page);
137
+ const nodes = buildVideoObjectNodes(ctx);
138
+
139
+ expect(nodes[0]!["@id"]).not.toBe(nodes[1]!["@id"]);
140
+ expect(nodes[0]!["@id"]).toContain("v1");
141
+ expect(nodes[1]!["@id"]).toContain("v2");
142
+ });
143
+ });
@@ -0,0 +1,69 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Test that buildWebPageNode emits correct speakable cssSelector matching the rendered section-header__subheading class.</purpose>
4
+ </MODULE_CONTRACT>
5
+ <CHANGE_SUMMARY>
6
+ <item>Initial test for speakable cssSelector referencing section-header__subheading (not section-header__lead).</item>
7
+ </CHANGE_SUMMARY>
8
+ */
9
+
10
+ import { describe, expect, it } from "vitest";
11
+ import { buildWebPageNode } from "../semantic/jsonld/webpage.ts";
12
+ import { createJsonLdContext } from "../semantic/jsonld/context.ts";
13
+ import type { SemanticPageModel, SemanticOrganization } from "../semantic/models.ts";
14
+
15
+ function makeModel(overrides: Partial<SemanticPageModel> = {}): SemanticPageModel {
16
+ const org: SemanticOrganization = {
17
+ name: "Test Org",
18
+ description: "Test organization",
19
+ url: "https://example.com",
20
+ };
21
+ return {
22
+ type: "home",
23
+ lang: "de",
24
+ url: "https://example.com/de",
25
+ title: "Test Page",
26
+ description: "Test description",
27
+ breadcrumbs: [],
28
+ blocks: [],
29
+ organization: org,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe("buildWebPageNode speakable cssSelector", () => {
35
+ it("references section-header__subheading (not section-header__lead) when page has lead", () => {
36
+ const model = makeModel({
37
+ lead: "Test lead text",
38
+ blocks: [{ id: "block-1", heading: "Section heading" }],
39
+ });
40
+ const context = createJsonLdContext(model);
41
+ const node = buildWebPageNode(context);
42
+ const speakable = node.speakable as { cssSelector: string[] };
43
+ expect(speakable).toBeDefined();
44
+ expect(speakable.cssSelector).toContain("h1");
45
+ expect(speakable.cssSelector).toContain(".section-header__subheading");
46
+ expect(speakable.cssSelector).not.toContain(".section-header__lead");
47
+ });
48
+
49
+ it("emits only h1 selector when page has no lead", () => {
50
+ const model = makeModel({
51
+ blocks: [{ id: "block-1", heading: "Section heading" }],
52
+ });
53
+ const context = createJsonLdContext(model);
54
+ const node = buildWebPageNode(context);
55
+ const speakable = node.speakable as { cssSelector: string[] };
56
+ expect(speakable).toBeDefined();
57
+ expect(speakable.cssSelector).toEqual(["h1"]);
58
+ });
59
+
60
+ it("omits speakable when no blocks have headings", () => {
61
+ const model = makeModel({
62
+ lead: "Test lead text",
63
+ blocks: [{ id: "block-1", heading: "" }],
64
+ });
65
+ const context = createJsonLdContext(model);
66
+ const node = buildWebPageNode(context);
67
+ expect(node.speakable).toBeUndefined();
68
+ });
69
+ });
@@ -72,3 +72,55 @@ describe("RFC-0745: buildOrganizationNode makesOffer priceCurrency", () => {
72
72
  expect(makesOffer[1].priceCurrency).toBe("EUR");
73
73
  });
74
74
  });
75
+
76
+ describe("buildOrganizationNode priceRange", () => {
77
+ it("emits priceRange as min–max with currency when multiple prices exist", () => {
78
+ const org = makeOrgWithPrices([
79
+ { id: "monthly", label: "Monthly", amount: "70.00", currency: "EUR" },
80
+ { id: "yearly", label: "Yearly", amount: "700.00", currency: "EUR" },
81
+ { id: "setup", label: "Setup", amount: "200.00", currency: "EUR" },
82
+ ]);
83
+ const context = createJsonLdContext(makeModel(org));
84
+ const node = buildOrganizationNode(context);
85
+ expect(node.priceRange).toBe("70–700 EUR");
86
+ });
87
+
88
+ it("emits single-amount priceRange when all prices are equal", () => {
89
+ const org = makeOrgWithPrices([
90
+ { id: "monthly", label: "Monthly", amount: "70.00", currency: "EUR" },
91
+ ]);
92
+ const context = createJsonLdContext(makeModel(org));
93
+ const node = buildOrganizationNode(context);
94
+ expect(node.priceRange).toBe("70 EUR");
95
+ });
96
+
97
+ it("omits priceRange when no prices exist", () => {
98
+ const org: SemanticOrganization = {
99
+ name: "Test Org",
100
+ description: "Test organization",
101
+ url: "https://example.com",
102
+ };
103
+ const context = createJsonLdContext(makeModel(org));
104
+ const node = buildOrganizationNode(context);
105
+ expect(node.priceRange).toBeUndefined();
106
+ });
107
+
108
+ it("omits priceRange when price amounts are non-numeric", () => {
109
+ const org = makeOrgWithPrices([
110
+ { id: "custom", label: "Custom", amount: "auf Anfrage", currency: "EUR" },
111
+ ]);
112
+ const context = createJsonLdContext(makeModel(org));
113
+ const node = buildOrganizationNode(context);
114
+ expect(node.priceRange).toBe("");
115
+ });
116
+
117
+ it("emits priceRange without currency when prices lack currency", () => {
118
+ const org = makeOrgWithPrices([
119
+ { id: "monthly", label: "Monthly", amount: "70.00" },
120
+ { id: "setup", label: "Setup", amount: "200.00" },
121
+ ]);
122
+ const context = createJsonLdContext(makeModel(org));
123
+ const node = buildOrganizationNode(context);
124
+ expect(node.priceRange).toBe("70–200");
125
+ });
126
+ });