@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
@@ -0,0 +1,123 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>RFC-0899: Unit tests for access protection middleware logic — constant-time comparison, host matching, PIN gating.</purpose>
4
+ </MODULE_CONTRACT>
5
+ <CHANGE_SUMMARY>
6
+ <item>RFC-0899: Initial middleware unit tests.</item>
7
+ </CHANGE_SUMMARY>
8
+ */
9
+
10
+ import { describe, expect, it, vi } from "vitest";
11
+
12
+ const mockEnv: Record<string, unknown> = {};
13
+ vi.mock("cloudflare:workers", () => ({
14
+ env: mockEnv,
15
+ }));
16
+ vi.mock("astro:middleware", () => ({
17
+ defineMiddleware: (handler: unknown) => handler,
18
+ }));
19
+
20
+ describe("RFC-0899: access protection middleware", () => {
21
+ async function loadMiddleware() {
22
+ return (await import("../access-protection")).accessProtectionMiddleware as (
23
+ context: unknown,
24
+ next: () => Promise<Response>,
25
+ ) => Promise<Response>;
26
+ }
27
+
28
+ function makeContext(host: string, authHeader?: string) {
29
+ const headers = new Map<string, string>();
30
+ headers.set("host", host);
31
+ if (authHeader) headers.set("authorization", authHeader);
32
+ return {
33
+ request: {
34
+ headers: {
35
+ get: (name: string) => headers.get(name.toLowerCase()) ?? null,
36
+ },
37
+ },
38
+ };
39
+ }
40
+
41
+ async function runMiddleware(
42
+ handler: (context: unknown, next: () => Promise<Response>) => Promise<Response>,
43
+ host: string,
44
+ authHeader?: string,
45
+ ): Promise<Response & { _nextCalled: boolean }> {
46
+ let nextCalled = false;
47
+ const nextResponse = new Response("page content", { status: 200 });
48
+ const result = await handler(makeContext(host, authHeader), async () => {
49
+ nextCalled = true;
50
+ return nextResponse;
51
+ });
52
+ return Object.assign(result, { _nextCalled: nextCalled });
53
+ }
54
+
55
+ it("passes through for main domain (no dev/alt prefix)", async () => {
56
+ mockEnv.ACCESS_PIN = "1234";
57
+ const handler = await loadMiddleware();
58
+ const res = await runMiddleware(handler, "example.com");
59
+ expect(res._nextCalled).toBe(true);
60
+ expect(res.status).toBe(200);
61
+ });
62
+
63
+ it("returns 401 for dev.* without auth header when PIN is set", async () => {
64
+ mockEnv.ACCESS_PIN = "1234";
65
+ const handler = await loadMiddleware();
66
+ const res = await runMiddleware(handler, "dev.example.com");
67
+ expect(res._nextCalled).toBe(false);
68
+ expect(res.status).toBe(401);
69
+ expect(res.headers.get("WWW-Authenticate")).toBe('Basic realm="Staging Access"');
70
+ expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow, noai, noimageai");
71
+ });
72
+
73
+ it("returns 401 for alt.* without auth header when PIN is set", async () => {
74
+ mockEnv.ACCESS_PIN = "1234";
75
+ const handler = await loadMiddleware();
76
+ const res = await runMiddleware(handler, "alt.example.com");
77
+ expect(res._nextCalled).toBe(false);
78
+ expect(res.status).toBe(401);
79
+ });
80
+
81
+ it("passes through dev.* with correct Basic Auth", async () => {
82
+ mockEnv.ACCESS_PIN = "1234";
83
+ const expected = `Basic ${btoa("warp:1234")}`;
84
+ const handler = await loadMiddleware();
85
+ const res = await runMiddleware(handler, "dev.example.com", expected);
86
+ expect(res._nextCalled).toBe(true);
87
+ expect(res.status).toBe(200);
88
+ expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow, noai, noimageai");
89
+ });
90
+
91
+ it("returns 401 for dev.* with wrong PIN", async () => {
92
+ mockEnv.ACCESS_PIN = "1234";
93
+ const wrong = `Basic ${btoa("warp:9999")}`;
94
+ const handler = await loadMiddleware();
95
+ const res = await runMiddleware(handler, "dev.example.com", wrong);
96
+ expect(res._nextCalled).toBe(false);
97
+ expect(res.status).toBe(401);
98
+ });
99
+
100
+ it("passes through dev.* when PIN is not set (no env var)", async () => {
101
+ delete mockEnv.ACCESS_PIN;
102
+ const handler = await loadMiddleware();
103
+ const res = await runMiddleware(handler, "dev.example.com");
104
+ expect(res._nextCalled).toBe(true);
105
+ expect(res.status).toBe(200);
106
+ expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow, noai, noimageai");
107
+ });
108
+
109
+ it("passes through alt.* when PIN env is undefined", async () => {
110
+ mockEnv.ACCESS_PIN = undefined;
111
+ const handler = await loadMiddleware();
112
+ const res = await runMiddleware(handler, "alt.example.com");
113
+ expect(res._nextCalled).toBe(true);
114
+ expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow, noai, noimageai");
115
+ });
116
+
117
+ it("does not set X-Robots-Tag on main domain", async () => {
118
+ mockEnv.ACCESS_PIN = "1234";
119
+ const handler = await loadMiddleware();
120
+ const res = await runMiddleware(handler, "example.com");
121
+ expect(res.headers.get("X-Robots-Tag")).toBe(null);
122
+ });
123
+ });
package/src/share/page.ts CHANGED
@@ -44,7 +44,7 @@ export interface PageEntry {
44
44
  }
45
45
 
46
46
  export interface BlockEntry {
47
- id?: string;
47
+ id: string;
48
48
  type?: string; // CMS-facing archetype slug — validated by page.block.validate
49
49
  use?: string; // PlanetName or MoonName — normalized from type for internal resolution
50
50
  props: Record<string, unknown>;
@@ -75,7 +75,7 @@ export interface ShellConfig {
75
75
  * All blocks in ResolvedPage.blocks passed visibility — invisible blocks are dropped.
76
76
  */
77
77
  export interface ResolvedBlock {
78
- /** Stable kebab-case block id from the content entry (null if not declared). */
78
+ /** Stable kebab-case block id from the content entry. Mandatory for content blocks (RFC-0914); null only for shell blocks injected by the pipeline. */
79
79
  readonly id: string | null;
80
80
  /** The PlanetName or MoonName identifying this block's archetype. */
81
81
  readonly planetName: string;
@@ -121,6 +121,8 @@ export interface SectionProps {
121
121
  defaultLanguageCode: string;
122
122
  /** Zero-padded section index (01, 02, ...) for anchors and styling */
123
123
  sectionNumber: string;
124
+ /** RFC-0914: Stable kebab-case block id from content entry, used as HTML id */
125
+ blockId: string;
124
126
  /** Optional link registry for CTA/link resolution */
125
127
  linkRegistry?: Record<string, string | null>;
126
128
  /** Complete block.props as declared in page frontmatter */
@@ -0,0 +1,36 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-0917 canonical placeholder route template filter. Detects Astro dynamic
5
+ route templates (e.g. `[slug]`, `[version]`) that are expanded by dedicated
6
+ route generators, not actual pages. All system.md consumers MUST import this
7
+ utility instead of reimplementing the bracket-detection check inline.
8
+ </purpose>
9
+ <non-goals>
10
+ <item>Do not expand or resolve placeholder templates — only detect them.</item>
11
+ <item>Do not validate route syntax — bracket presence is the only signal.</item>
12
+ </non-goals>
13
+ </MODULE_CONTRACT>
14
+ <CHANGE_SUMMARY>
15
+ <item>RFC-0917: Initial creation — centralize placeholder route template filtering.</item>
16
+ </CHANGE_SUMMARY>
17
+ */
18
+
19
+ /**
20
+ * Returns `true` if any route value in the given routes map contains
21
+ * `[` or `]` characters, indicating an Astro dynamic route template
22
+ * (e.g. `nachweis/[slug]`, `verify/[version]`).
23
+ *
24
+ * Returns `false` for `undefined`, `null`, or empty routes — safe default
25
+ * that avoids false positives when routes are missing or not yet resolved.
26
+ *
27
+ * Canonical utility (RFC-0917). All `system.md` consumers
28
+ * MUST import from `@warpgogol/werkstatt-shared/share/routes/template-filter`.
29
+ * Enforcement: `utility.provenance.validate` (RFC-0916).
30
+ */
31
+ export function hasPlaceholderRoutes(routes?: Record<string, string> | null): boolean {
32
+ if (!routes) return false;
33
+ return Object.values(routes).some(
34
+ (slug) => typeof slug === "string" && (slug.includes("[") || slug.includes("]")),
35
+ );
36
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { hasPlaceholderRoutes } from "../template-filter.ts";
3
+
4
+ describe("hasPlaceholderRoutes", () => {
5
+ it("returns true for routes containing [slug]", () => {
6
+ expect(hasPlaceholderRoutes({ de: "nachweis/[slug]" })).toBe(true);
7
+ });
8
+
9
+ it("returns true for routes containing [version]", () => {
10
+ expect(hasPlaceholderRoutes({ de: "verify/[version]" })).toBe(true);
11
+ });
12
+
13
+ it("returns true for routes containing [...path] (rest params)", () => {
14
+ expect(hasPlaceholderRoutes({ de: "docs/[...path]" })).toBe(true);
15
+ });
16
+
17
+ it("returns true for mixed routes (some plain, some placeholder)", () => {
18
+ expect(hasPlaceholderRoutes({ de: "home", en: "blog/[slug]" })).toBe(true);
19
+ });
20
+
21
+ it("returns false for plain routes without brackets", () => {
22
+ expect(hasPlaceholderRoutes({ de: "home", en: "about" })).toBe(false);
23
+ });
24
+
25
+ it("returns false for undefined routes", () => {
26
+ expect(hasPlaceholderRoutes(undefined)).toBe(false);
27
+ });
28
+
29
+ it("returns false for null routes", () => {
30
+ expect(hasPlaceholderRoutes(null)).toBe(false);
31
+ });
32
+
33
+ it("returns false for empty routes object", () => {
34
+ expect(hasPlaceholderRoutes({})).toBe(false);
35
+ });
36
+
37
+ it("returns false when route values are non-string", () => {
38
+ expect(hasPlaceholderRoutes({ de: 123 as unknown as string })).toBe(false);
39
+ });
40
+ });
@@ -79,6 +79,7 @@ export async function initLenis(
79
79
  offset: -headerOffset,
80
80
  immediate: prefersReducedMotion,
81
81
  });
82
+ history.replaceState(null, "", href);
82
83
  }
83
84
  }
84
85
  }
@@ -500,3 +500,27 @@ for (const blockType of PASSPORT_NOOP_TYPES) {
500
500
  },
501
501
  });
502
502
  }
503
+
504
+ // ---------------------------------------------------------------------------
505
+ // Site-specific block types that have no semantic text to extract but must be
506
+ // registered so page.blocks.extract.validate does not fail.
507
+ // ---------------------------------------------------------------------------
508
+ const SITE_NOOP_TYPES = [
509
+ "nachweis-list",
510
+ "nachweis-detail",
511
+ "nachweis-verify",
512
+ "gratitude",
513
+ "open-source-registry",
514
+ "mountain-journey",
515
+ "dynamic-status-block",
516
+ "service-metadata-block",
517
+ ] as const;
518
+
519
+ for (const blockType of SITE_NOOP_TYPES) {
520
+ BLOCK_EXTRACTORS.register<Record<string, unknown>>({
521
+ blockType,
522
+ extract() {
523
+ return { heading: "" };
524
+ },
525
+ });
526
+ }
@@ -10,6 +10,7 @@
10
10
  <CHANGE_SUMMARY>
11
11
  <item>RFC-0144: initial extraction of the shared per-page builder from the two duplicated paths.</item>
12
12
  <item>RFC-0372: unified all page types through extractContentBlocks + extractPageHeading; removed home-specific branch and extractMarkdownProps; extractContentBlocks returns SemanticBlock[].</item>
13
+ <item>RFC-0912: extract seo.videoObject opt-in from block props and attach VideoSeoData to SemanticBlock.video.</item>
13
14
  </CHANGE_SUMMARY>
14
15
  */
15
16
 
@@ -22,6 +23,7 @@ import type {
22
23
  SemanticPageModel,
23
24
  SemanticPageType,
24
25
  SemanticPerson,
26
+ VideoSeoData,
25
27
  } from "./models.ts";
26
28
  import { BLOCK_EXTRACTORS } from "./block-extraction.ts";
27
29
  import "./block-extractors/index.ts";
@@ -132,6 +134,31 @@ async function resolveFaqEntries(
132
134
  return faqEntries;
133
135
  }
134
136
 
137
+ /** RFC-0912: extract video SEO data from block props when seo.videoObject opt-in is present. */
138
+ function extractVideoSeoData(props: Record<string, unknown>): VideoSeoData | undefined {
139
+ const seo = props["seo"];
140
+ if (!seo || typeof seo !== "object") return undefined;
141
+ const seoRecord = seo as Record<string, unknown>;
142
+ if (seoRecord["videoObject"] !== true) return undefined;
143
+ const name = seoRecord["name"];
144
+ const description = seoRecord["description"];
145
+ const uploadDate = seoRecord["uploadDate"];
146
+ if (
147
+ typeof name !== "string" ||
148
+ typeof description !== "string" ||
149
+ typeof uploadDate !== "string"
150
+ ) {
151
+ return undefined;
152
+ }
153
+ // The manifest data (posterUrl, durationSec, contentUrl) is populated by the
154
+ // render layer when the variant manifest is available. At build-page time we
155
+ // carry the seo fields; the render layer fills in the manifest data.
156
+ return {
157
+ seo: { name, description, uploadDate },
158
+ manifest: { posterUrl: "", contentUrl: "" },
159
+ };
160
+ }
161
+
135
162
  /** RFC-0372: extract semantic text from declared blocks into SemanticBlock[]. */
136
163
  function extractContentBlocks(
137
164
  blocks: Array<Record<string, unknown>>,
@@ -140,7 +167,12 @@ function extractContentBlocks(
140
167
  const result: SemanticBlock[] = [];
141
168
  for (const block of blocks) {
142
169
  const blockType = String(block["type"] ?? "");
143
- const blockId = String(block["id"] ?? `block-${result.length}`);
170
+ const blockId = block["id"];
171
+ if (typeof blockId !== "string" || !blockId) {
172
+ throw new Error(
173
+ `[extractContentBlocks] Block #${result.length} in page ${ctx.pageId} (${ctx.lang}) is missing required \`id\` field. Run \`block.id.generate\` to backfill.`,
174
+ );
175
+ }
144
176
  if (!blockType) continue;
145
177
  const extractor = BLOCK_EXTRACTORS.get(blockType);
146
178
  if (!extractor) continue;
@@ -156,6 +188,12 @@ function extractContentBlocks(
156
188
  items: extracted.items,
157
189
  extractedAt: new Date().toISOString(),
158
190
  extractorVersion: "1.0.0",
191
+ // RFC-0912: attach video SEO data when the block has seo.videoObject opt-in.
192
+ ...((block["props"] ?? block) &&
193
+ typeof (block["props"] ?? block) === "object" &&
194
+ extractVideoSeoData((block["props"] ?? block) as Record<string, unknown>)
195
+ ? { video: extractVideoSeoData((block["props"] ?? block) as Record<string, unknown>) }
196
+ : {}),
159
197
  });
160
198
  }
161
199
  return result;
@@ -7,6 +7,7 @@
7
7
  </MODULE_CONTRACT>
8
8
  <CHANGE_SUMMARY>
9
9
  <item>RFC-0133: backfilled MODULE_MAP and CHANGE_SUMMARY markers for compass.validate compliance.</item>
10
+ <item>RFC-0915: removed custom slugify() — replaced by slugId from @warpgogol/werkstatt-shared/share/slug.</item>
10
11
  </CHANGE_SUMMARY>
11
12
  */
12
13
 
@@ -16,17 +17,6 @@ export function normalizeWhitespace(value: string): string {
16
17
  return value.replace(/\s+/g, " ").trim();
17
18
  }
18
19
 
19
- export function slugify(value: string): string {
20
- return (
21
- value
22
- .toLowerCase()
23
- .normalize("NFKD")
24
- .replace(/[\u0300-\u036f]/g, "")
25
- .replace(/[^a-z0-9]+/g, "-")
26
- .replace(/^-+|-+$/g, "") || "entity"
27
- );
28
- }
29
-
30
20
  export type MarkdownSection = {
31
21
  heading: string;
32
22
  body: string;
@@ -62,6 +52,90 @@ export function extractParagraphs(markdown: string): string[] {
62
52
  .filter(Boolean);
63
53
  }
64
54
 
55
+ const SENTENCE_ABBREVIATIONS: Record<string, string[]> = {
56
+ de: [
57
+ "z.B.",
58
+ "z. B.",
59
+ "z.",
60
+ "etc.",
61
+ "Nr.",
62
+ "Abs.",
63
+ "§",
64
+ "S.",
65
+ "ca.",
66
+ "u.a.",
67
+ "u. a.",
68
+ "u.",
69
+ "vgl.",
70
+ "bspw.",
71
+ ],
72
+ uk: ["т.д.", "т.п.", "п.", "ст.", "див.", "пор.", "напр.", "ім.", "о."],
73
+ en: ["e.g.", "i.e.", "etc.", "vs.", "Mr.", "Mrs.", "Dr.", "Inc.", "Ltd."],
74
+ };
75
+
76
+ function escapeRegex(text: string): string {
77
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
78
+ }
79
+
80
+ function buildAbbreviationPattern(abbreviations: string[]): RegExp | undefined {
81
+ if (abbreviations.length === 0) return undefined;
82
+ const sorted = [...abbreviations].sort((a, b) => b.length - a.length);
83
+ return new RegExp(`(?:^|[^a-zA-Zа-яА-ЯёЁїЇіІєЄäöüÄÖÜß])(${sorted.map(escapeRegex).join("|")})$`);
84
+ }
85
+
86
+ export function splitSentences(text: string, locale: string = "en"): string[] {
87
+ const normalized = text.trim();
88
+ if (!normalized) return [];
89
+
90
+ const abbreviations = SENTENCE_ABBREVIATIONS[locale] ?? SENTENCE_ABBREVIATIONS.en;
91
+ const abbrevPattern = buildAbbreviationPattern(abbreviations);
92
+
93
+ const sentences: string[] = [];
94
+ let current = "";
95
+
96
+ const chars = [...normalized];
97
+ for (let i = 0; i < chars.length; i++) {
98
+ const char = chars[i];
99
+ current += char;
100
+
101
+ if (char !== "." && char !== "!" && char !== "?") continue;
102
+
103
+ const nextChar = chars[i + 1];
104
+ if (nextChar === undefined) {
105
+ sentences.push(current.trim());
106
+ current = "";
107
+ continue;
108
+ }
109
+
110
+ if (nextChar !== " " && nextChar !== "\n" && nextChar !== "\t") continue;
111
+
112
+ const beforeAbbrCheck = current.trimEnd();
113
+
114
+ if (abbrevPattern && abbrevPattern.test(beforeAbbrCheck)) continue;
115
+
116
+ // Skip numbered list markers (e.g., "1. ", "2. ") — a period after
117
+ // digits is a list marker, not a sentence boundary. After normalizeWhitespace
118
+ // newlines are spaces, so we check the token before the period.
119
+ const beforePeriod = current.slice(0, -1);
120
+ const lastSpaceIdx = Math.max(beforePeriod.lastIndexOf(" "), beforePeriod.lastIndexOf("\n"));
121
+ const tokenBeforePeriod = beforePeriod.slice(lastSpaceIdx + 1).trim();
122
+ if (/^\d+$/.test(tokenBeforePeriod)) continue;
123
+
124
+ const afterWhitespace = chars
125
+ .slice(i + 1)
126
+ .join("")
127
+ .match(/^\s*([A-ZÄÖÜА-ЯЁЇІЄ])/);
128
+ if (!afterWhitespace) continue;
129
+
130
+ sentences.push(current.trim());
131
+ current = "";
132
+ }
133
+
134
+ if (current.trim()) sentences.push(current.trim());
135
+
136
+ return sentences.filter(Boolean);
137
+ }
138
+
65
139
  export function extractListFacts(markdown: string): string[] {
66
140
  return markdown
67
141
  .split("\n")
@@ -8,6 +8,7 @@
8
8
  </MODULE_CONTRACT>
9
9
  <CHANGE_SUMMARY>
10
10
  <item>Moved from app semantic/ids to packages/share/src/semantic/ids.ts — framework-agnostic, reusable across all apps.</item>
11
+ <item>RFC-0910: add canonicalRootUrl — unprefixed root URL for entity identity (Organization.url, WebSite.url).</item>
11
12
  </CHANGE_SUMMARY>
12
13
  */
13
14
 
@@ -27,6 +28,18 @@ export function toAbsoluteUrl(baseUrl: string, path: string): string {
27
28
  return new URL(path, `${baseUrl}/`).toString();
28
29
  }
29
30
 
31
+ /**
32
+ * RFC-0910: produce the canonical root URL for entity identity.
33
+ *
34
+ * The entity root URL is language-independent — it is always `https://site/`
35
+ * regardless of the default language. This contrasts with page URLs, which
36
+ * are language-prefixed for non-default languages and unprefixed for the
37
+ * default language per RFC-0160.
38
+ */
39
+ export function canonicalRootUrl(baseUrl: string): string {
40
+ return new URL("/", `${baseUrl}/`).toString();
41
+ }
42
+
30
43
  export function toPathname(url: string): string {
31
44
  return new URL(url).pathname;
32
45
  }
@@ -97,5 +97,25 @@ export function buildOrganizationNode(context: JsonLdContext): JsonLdNode {
97
97
  ? { logo: { "@type": "ImageObject", url: page.organization.logo } }
98
98
  : {}),
99
99
  ...(page.organization.image ? { image: page.organization.image } : {}),
100
+ ...(page.organization.offer?.prices?.length
101
+ ? { priceRange: buildPriceRange(page.organization.offer.prices) }
102
+ : {}),
100
103
  };
101
104
  }
105
+
106
+ function buildPriceRange(prices: Array<{ amount: string; currency?: string }>): string {
107
+ const numericAmounts = prices
108
+ .map((p) => Number.parseFloat(p.amount))
109
+ .filter((n) => !Number.isNaN(n));
110
+ if (numericAmounts.length === 0) return "";
111
+ const min = Math.min(...numericAmounts);
112
+ const max = Math.max(...numericAmounts);
113
+ const currency = prices.find((p) => p.currency)?.currency;
114
+ const formatAmount = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(2));
115
+ if (min === max) {
116
+ return currency ? `${formatAmount(min)} ${currency}` : formatAmount(min);
117
+ }
118
+ return currency
119
+ ? `${formatAmount(min)}–${formatAmount(max)} ${currency}`
120
+ : `${formatAmount(min)}–${formatAmount(max)}`;
121
+ }
@@ -0,0 +1,56 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>RFC-0912: builds VideoObject JSON-LD nodes from SemanticBlock.video data for opted-in content videos. Reads variant-manifest-derived data populated by buildSemanticPageModelWith.</purpose>
4
+ <non-goals>
5
+ <item>Do not read the variant manifest directly — buildSemanticPageModelWith populates SemanticBlock.video before buildJsonLd runs.</item>
6
+ <item>Do not emit VideoObject for blocks without the seo.videoObject opt-in.</item>
7
+ </non-goals>
8
+ </MODULE_CONTRACT>
9
+ <CHANGE_SUMMARY>
10
+ <item>RFC-0912: initial implementation.</item>
11
+ </CHANGE_SUMMARY>
12
+ */
13
+
14
+ import type { JsonLdContext } from "./context.ts";
15
+ import type { JsonLdNode } from "./types.ts";
16
+
17
+ function formatDuration(seconds: number): string {
18
+ const totalSec = Math.round(seconds);
19
+ const hours = Math.floor(totalSec / 3600);
20
+ const minutes = Math.floor((totalSec % 3600) / 60);
21
+ const secs = totalSec % 60;
22
+ const parts = ["PT"];
23
+ if (hours > 0) parts.push(`${hours}H`);
24
+ if (minutes > 0) parts.push(`${minutes}M`);
25
+ parts.push(`${secs}S`);
26
+ return parts.join("");
27
+ }
28
+
29
+ export function buildVideoObjectNodes(context: JsonLdContext): JsonLdNode[] {
30
+ const { page, webpageId } = context;
31
+ const nodes: JsonLdNode[] = [];
32
+
33
+ for (const block of page.blocks) {
34
+ if (!block.video) continue;
35
+
36
+ const { seo, manifest } = block.video;
37
+ const nodeId = `${webpageId.replace("#/schema/webpage", "#/schema/video")}/${block.id}`;
38
+
39
+ const node: JsonLdNode = {
40
+ "@type": "VideoObject",
41
+ "@id": nodeId,
42
+ name: seo.name,
43
+ description: seo.description,
44
+ uploadDate: seo.uploadDate,
45
+ thumbnailUrl: manifest.posterUrl,
46
+ contentUrl: manifest.contentUrl,
47
+ ...(manifest.durationSec != null
48
+ ? { duration: formatDuration(manifest.durationSec) }
49
+ : {}),
50
+ };
51
+
52
+ nodes.push(node);
53
+ }
54
+
55
+ return nodes;
56
+ }
@@ -90,7 +90,7 @@ export function buildWebPageNode(context: JsonLdContext): JsonLdNode {
90
90
  ? {
91
91
  speakable: {
92
92
  "@type": "SpeakableSpecification",
93
- cssSelector: page.lead ? ["h1", ".section-header__lead"] : ["h1"],
93
+ cssSelector: page.lead ? ["h1", ".section-header__subheading"] : ["h1"],
94
94
  },
95
95
  }
96
96
  : {}),
@@ -8,6 +8,7 @@
8
8
  </MODULE_CONTRACT>
9
9
  <CHANGE_SUMMARY>
10
10
  <item>Added initiative list graph node, breadcrumb fallback, and shared dedupe utility.</item>
11
+ <item>RFC-0912: added VideoObject node composition for opted-in content video blocks.</item>
11
12
  </CHANGE_SUMMARY>
12
13
  */
13
14
 
@@ -22,12 +23,14 @@ import { buildPersonNodes } from "./jsonld/person.ts";
22
23
  import { buildServiceNodes } from "./jsonld/service.ts";
23
24
  import { dedupeGraph } from "./jsonld/shared.ts";
24
25
  import type { JsonLdDocument } from "./jsonld/types.ts";
26
+ import { buildVideoObjectNodes } from "./jsonld/video.ts";
25
27
  import { buildWebPageNode } from "./jsonld/webpage.ts";
26
28
  import { buildWebSiteNode } from "./jsonld/website.ts";
27
29
  import type { SemanticPageModel } from "./models.ts";
28
30
 
29
31
  export type { JsonLdDocument } from "./jsonld/types.ts";
30
32
  export type { JsonLdContext } from "./jsonld/context.ts";
33
+ export { buildVideoObjectNodes } from "./jsonld/video.ts";
31
34
 
32
35
  export function buildJsonLd(page: SemanticPageModel): JsonLdDocument {
33
36
  const context = createJsonLdContext(page);
@@ -68,6 +71,8 @@ export function buildJsonLd(page: SemanticPageModel): JsonLdDocument {
68
71
  ...(collectionListNode ? [collectionListNode] : []),
69
72
  ...(articleNode ? [articleNode] : []),
70
73
  ...(breadcrumbNode ? [breadcrumbNode] : []),
74
+ // RFC-0912: VideoObject nodes for opted-in content video blocks.
75
+ ...buildVideoObjectNodes(context),
71
76
  // RFC-0512: extra nodes from team profile pages (SoftwareApplication, CollectionPage).
72
77
  ...(page.extraGraphNodes ?? []),
73
78
  ]),
@@ -12,6 +12,7 @@
12
12
  <item>RFC-0490: Added "collection" to the SemanticPageType closed enum.</item>
13
13
  <item>RFC-0508: Added "participant" to the SemanticPageType closed enum.</item>
14
14
  <item>RFC-0372: Unified SemanticBlock type replaces SemanticAnswerBlock + SemanticContentBlock; SemanticPageModel.blocks replaces answerBlocks/contentBlocks/bodyText.</item>
15
+ <item>RFC-0912: Added VideoSeoData type and optional SemanticBlock.video field for opted-in content video structured data.</item>
15
16
  </CHANGE_SUMMARY>
16
17
  */
17
18
 
@@ -59,6 +60,12 @@ export type SemanticBreadcrumb = {
59
60
  * Every block in a SemanticPageModel is represented by this single type, regardless of
60
61
  * whether it was derived from prose parsing or frontmatter block extraction.
61
62
  */
63
+ /** RFC-0912: video SEO data populated by buildSemanticPageModelWith for opted-in content video blocks. */
64
+ export type VideoSeoData = {
65
+ seo: { name: string; description: string; uploadDate: string };
66
+ manifest: { posterUrl: string; durationSec?: number; contentUrl: string };
67
+ };
68
+
62
69
  export type SemanticBlock = {
63
70
  /** Stable id from frontmatter block.id (required) or slugified heading for prose-derived blocks. */
64
71
  id: string;
@@ -77,6 +84,8 @@ export type SemanticBlock = {
77
84
  /** Extractor metadata (absent for prose-derived blocks). */
78
85
  extractedAt?: string;
79
86
  extractorVersion?: string;
87
+ /** RFC-0912: video SEO data for opted-in content video blocks (seo.videoObject: true). Populated by buildSemanticPageModelWith from the variant manifest. */
88
+ video?: VideoSeoData;
80
89
  };
81
90
 
82
91
  /* RFC-0142: per-page llms inclusion depth. */
@@ -8,10 +8,11 @@
8
8
  </MODULE_CONTRACT>
9
9
  <CHANGE_SUMMARY>
10
10
  <item>RFC-0148: extracted the shared org-profile assembler from the disk + Astro builders.</item>
11
+ <item>RFC-0910: Organization.url uses canonicalRootUrl (unprefixed root) instead of language-prefixed path.</item>
11
12
  </CHANGE_SUMMARY>
12
13
  */
13
14
 
14
- import { getBaseUrl, toAbsoluteUrl } from "./ids.ts";
15
+ import { canonicalRootUrl, getBaseUrl, toAbsoluteUrl } from "./ids.ts";
15
16
  import type {
16
17
  SemanticInitiative,
17
18
  SemanticLocation,
@@ -104,7 +105,7 @@ export function buildOrganizationProfile(input: OrganizationProfileInput): Seman
104
105
  name: input.brandName,
105
106
  legalName: input.legalName,
106
107
  description: input.description,
107
- url: toAbsoluteUrl(baseUrl, `/${input.lang}/`),
108
+ url: canonicalRootUrl(baseUrl),
108
109
  foundingYear: input.foundingYear,
109
110
  email: input.email,
110
111
  registration: input.registration,