@warpgogol/werkstatt-shared 0.8.0 → 0.8.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/package.json +28 -15
- package/src/checks/index.ts +1 -0
- package/src/checks/result-helpers.ts +60 -6
- package/src/checks/suppressions-config.ts +26 -1
- package/src/content/system-manifest.ts +8 -0
- package/src/ontology/archetypes/index.json +17 -1
- package/src/ontology/schemas/page-entry.ts +5 -7
- package/src/ontology/schemas/system/manifest.ts +8 -0
- package/src/ontology/schemas/system/verification.ts +40 -0
- package/src/passport/sign.ts +20 -14
- package/src/share/middleware/access-protection.ts +174 -0
- package/src/share/middleware/tests/access-protection.test.ts +123 -0
- package/src/share/page.ts +4 -2
- package/src/share/routes/template-filter.ts +36 -0
- package/src/share/routes/tests/template-filter.test.ts +40 -0
- package/src/share/scripts/lenis.ts +1 -0
- package/src/share/semantic/block-extractors/index.ts +24 -0
- package/src/share/semantic/build-page.ts +39 -1
- package/src/share/semantic/extract.ts +85 -11
- package/src/share/semantic/ids.ts +13 -0
- package/src/share/semantic/jsonld/organization.ts +20 -0
- package/src/share/semantic/jsonld/video.ts +56 -0
- package/src/share/semantic/jsonld/webpage.ts +1 -1
- package/src/share/semantic/jsonld.ts +5 -0
- package/src/share/semantic/models.ts +9 -0
- package/src/share/semantic/organization-profile.ts +3 -2
- package/src/share/semantic/page-utils.ts +4 -10
- package/src/share/semantic/tests/split-sentences.test.ts +97 -0
- package/src/share/slug/heading-slugger.ts +26 -0
- package/src/share/slug/index.ts +15 -0
- package/src/share/slug/slug-id.ts +22 -0
- package/src/share/slug/slug-url.ts +24 -0
- package/src/share/slug/strategies.ts +68 -0
- package/src/share/slug/tests/slug.test.ts +84 -0
- package/src/share/tests/build-page-price-markers.test.ts +3 -0
- package/src/share/tests/jsonld-video.test.ts +143 -0
- package/src/share/tests/jsonld-webpage.test.ts +69 -0
- package/src/share/tests/organization-jsonld.test.ts +52 -0
- package/src/share/types/axiom-study.d.ts +10 -2
- package/src/share/utility-registry.yaml +36 -0
- package/src/surface/blueprint-schema.ts +1 -1
- package/src/surface/blueprint-types.ts +1 -1
|
@@ -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
|
+
});
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
declare module "@syrokomskyi/axiom-study" {
|
|
2
2
|
export interface Finding {
|
|
3
3
|
findingId: string;
|
|
4
|
-
semanticFingerprint: string;
|
|
4
|
+
semanticFingerprint: { algorithm: "sha256"; digest: string; size: number; mediaType: string };
|
|
5
5
|
methodologyId: string;
|
|
6
6
|
ruleId: string;
|
|
7
7
|
affectedSubjectId: string;
|
|
8
8
|
title: string;
|
|
9
9
|
severity: "info" | "low" | "medium" | "high" | "critical";
|
|
10
|
-
evidence: Array<{
|
|
10
|
+
evidence: Array<{
|
|
11
|
+
evidenceRef: {
|
|
12
|
+
artifactId: string;
|
|
13
|
+
rootDigest: { algorithm: "sha256"; digest: string; size: number; mediaType: string };
|
|
14
|
+
schema: string;
|
|
15
|
+
};
|
|
16
|
+
selector: string;
|
|
17
|
+
evidenceClass: string;
|
|
18
|
+
}>;
|
|
11
19
|
uncertainty: unknown[];
|
|
12
20
|
extension: Record<string, unknown>;
|
|
13
21
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
utilities:
|
|
2
|
+
- id: slug
|
|
3
|
+
canonicalPath: packages/werkstatt-shared/src/share/slug/
|
|
4
|
+
forbiddenImports:
|
|
5
|
+
- "@sindresorhus/slugify"
|
|
6
|
+
- "cyrillic-to-translit-js"
|
|
7
|
+
- "github-slugger"
|
|
8
|
+
functionNames:
|
|
9
|
+
- slugify
|
|
10
|
+
- toSlug
|
|
11
|
+
- makeSlug
|
|
12
|
+
- createSlug
|
|
13
|
+
- slugUrl
|
|
14
|
+
- slugId
|
|
15
|
+
patterns:
|
|
16
|
+
- id: nkfd-diacritic-strip
|
|
17
|
+
regex: "\\.normalize\\(.{0,5}NFKD.{0,5}\\)\\.replace"
|
|
18
|
+
description: NFKD normalize + diacritic strip + non-alphanumeric replace (legacy custom slugify pattern)
|
|
19
|
+
allowlist:
|
|
20
|
+
- path: packages/werkstatt-shared/src/share/slug/
|
|
21
|
+
reason: Canonical slug module — owns all slug generation logic (RFC-0915, DNA-88)
|
|
22
|
+
- path: packages/werkstatt-shared/src/share/semantic/extract.ts
|
|
23
|
+
reason: CHANGE_SUMMARY comment references slugify removal — no function definition remains
|
|
24
|
+
- path: packages/werkstatt-site/src/checks/tests/utility-provenance.test.ts
|
|
25
|
+
reason: Test file intentionally contains forbidden patterns to validate the provenance validator (RFC-0916)
|
|
26
|
+
- path: packages/werkstatt-site/src/checks/utility-provenance.ts
|
|
27
|
+
reason: Validator implementation references forbidden imports/function names in string literals for detection logic (RFC-0916)
|
|
28
|
+
- id: template-filter
|
|
29
|
+
canonicalPath: packages/werkstatt-shared/src/share/routes/
|
|
30
|
+
forbiddenImports: []
|
|
31
|
+
functionNames:
|
|
32
|
+
- hasPlaceholderRoutes
|
|
33
|
+
patterns: []
|
|
34
|
+
allowlist:
|
|
35
|
+
- path: packages/werkstatt-shared/src/share/routes/
|
|
36
|
+
reason: Canonical placeholder route filter — owns all template detection logic (RFC-0917)
|
|
@@ -53,7 +53,7 @@ const pillarAdaptationSchema = z.object({
|
|
|
53
53
|
const pillarProductPriceSchema = z.object({
|
|
54
54
|
heading: localizedString,
|
|
55
55
|
body: localizedString,
|
|
56
|
-
priceRef: z.string().min(1),
|
|
56
|
+
priceRef: z.string().min(1).optional(),
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
const pillarFinalCtaSchema = z.object({
|